Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 442413581d | |||
| 6e392ca4a6 | |||
| edb118a5a0 | |||
| bf137350c6 | |||
| 1c7d0deef8 | |||
| a79a0489c5 | |||
| 91ef044e9c | |||
| cb1b13ebf6 | |||
| ef62eb7603 |
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Trace 上报脚本 - 用于 Gitea Actions workflow 中上报 Trace 数据到 AgentLoop
|
||||
|
||||
在 CI workflow 的每个 job 中调用:
|
||||
- 开始时:python3 .gitea/scripts/ci_trace_report.py --status running
|
||||
- 结束时:python3 .gitea/scripts/ci_trace_report.py --status ok --start-time $CI_TRACE_START_TIME
|
||||
|
||||
环境变量(Gitea Actions 内置):
|
||||
GITEA_REPOSITORY / GITHUB_REPOSITORY - 仓库名 (owner/repo)
|
||||
GITEA_WORKFLOW / GITHUB_WORKFLOW - workflow 名称
|
||||
GITEA_JOB / GITHUB_JOB - job ID
|
||||
GITEA_SHA / GITHUB_SHA - commit SHA
|
||||
GITEA_REF_NAME / GITHUB_REF_NAME - 分支名
|
||||
GITEA_RUN_ID / GITHUB_RUN_ID - run ID
|
||||
GITEA_ACTOR / GITHUB_ACTOR - 触发者
|
||||
GITEA_EVENT_NAME / GITHUB_EVENT_NAME - 事件类型
|
||||
PR_NUMBER / GITEA_PR_NUMBER - PR 号(如果是 PR 触发)
|
||||
|
||||
AgentLoop 配置(通过 Secrets 注入):
|
||||
AGENTLOOP_LICENSE_KEY - LicenseKey(必填)
|
||||
AGENTLOOP_ENDPOINT - Trace 上报地址(可选,有默认值)
|
||||
AGENTLOOP_PROJECT - SLS Project 名(可选)
|
||||
AGENTLOOP_WORKSPACE - CMS Workspace 名(可选)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
# ========== 默认配置 ==========
|
||||
DEFAULT_ENDPOINT = "https://proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou.cn-hangzhou.log.aliyuncs.com/apm/trace/opentelemetry/v1/traces"
|
||||
DEFAULT_PROJECT = "proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou"
|
||||
DEFAULT_WORKSPACE = "agentloop-13b8d6efb7fde6e9b193eb982ade68e2"
|
||||
|
||||
|
||||
# ========== OTLP Protobuf 手动编码 ==========
|
||||
|
||||
def _encode_varint(value):
|
||||
result = bytearray()
|
||||
while value > 0x7F:
|
||||
result.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
result.append(value & 0x7F)
|
||||
return bytes(result)
|
||||
|
||||
def _encode_tag(field_number, wire_type):
|
||||
return _encode_varint((field_number << 3) | wire_type)
|
||||
|
||||
def _encode_string_field(field_number, value):
|
||||
value_bytes = value.encode('utf-8')
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
def _encode_bytes_field(field_number, value_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
def _encode_int_field(field_number, value):
|
||||
return _encode_tag(field_number, 0) + _encode_varint(value & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def _encode_message_field(field_number, message_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(message_bytes)) + message_bytes
|
||||
|
||||
def _encode_key_value(key, value_str):
|
||||
any_value = _encode_string_field(1, value_str)
|
||||
return _encode_string_field(1, key) + _encode_message_field(2, any_value)
|
||||
|
||||
def _encode_status(status_code, status_msg=""):
|
||||
data = _encode_int_field(1, status_code)
|
||||
if status_msg:
|
||||
data += _encode_string_field(2, status_msg)
|
||||
return data
|
||||
|
||||
def _encode_span(trace_id_bytes, span_id_bytes, parent_span_id_bytes,
|
||||
name, start_time_unix_nano, end_time_unix_nano,
|
||||
span_kind, attributes, status_code, status_msg=""):
|
||||
data = b""
|
||||
data += _encode_bytes_field(1, trace_id_bytes)
|
||||
data += _encode_bytes_field(2, span_id_bytes)
|
||||
if parent_span_id_bytes:
|
||||
data += _encode_bytes_field(3, parent_span_id_bytes)
|
||||
data += _encode_string_field(4, name)
|
||||
data += _encode_int_field(5, span_kind)
|
||||
data += _encode_int_field(6, start_time_unix_nano)
|
||||
data += _encode_int_field(7, end_time_unix_nano)
|
||||
for key, value in attributes.items():
|
||||
kv = _encode_key_value(key, str(value))
|
||||
data += _encode_message_field(9, kv)
|
||||
status = _encode_status(status_code, status_msg)
|
||||
data += _encode_message_field(12, status)
|
||||
return data
|
||||
|
||||
def _encode_resource_spans(service_name, scope_spans_bytes):
|
||||
svc_kv = _encode_key_value("service.name", service_name)
|
||||
resource = _encode_message_field(1, svc_kv)
|
||||
data = _encode_message_field(1, resource)
|
||||
data += _encode_message_field(2, scope_spans_bytes)
|
||||
return data
|
||||
|
||||
def _encode_scope_spans(scope_name, spans_bytes_list):
|
||||
scope = _encode_string_field(1, scope_name)
|
||||
data = _encode_message_field(1, scope)
|
||||
for span_bytes in spans_bytes_list:
|
||||
data += _encode_message_field(2, span_bytes)
|
||||
return data
|
||||
|
||||
def _encode_traces_data(resource_spans_bytes_list):
|
||||
data = b""
|
||||
for rs_bytes in resource_spans_bytes_list:
|
||||
data += _encode_message_field(1, rs_bytes)
|
||||
return data
|
||||
|
||||
|
||||
# ========== 辅助函数 ==========
|
||||
|
||||
def _gen_trace_id():
|
||||
return uuid.uuid4().bytes
|
||||
|
||||
def _gen_span_id():
|
||||
return uuid.uuid4().bytes[:8]
|
||||
|
||||
def _env(name, default=""):
|
||||
"""获取环境变量,支持 GITEA_ 和 GITHUB_ 前缀"""
|
||||
val = os.getenv(name, "")
|
||||
if val:
|
||||
return val
|
||||
# 尝试另一种前缀
|
||||
if name.startswith("GITEA_"):
|
||||
alt = "GITHUB_" + name[6:]
|
||||
return os.getenv(alt, default)
|
||||
if name.startswith("GITHUB_"):
|
||||
alt = "GITEA_" + name[7:]
|
||||
return os.getenv(alt, default)
|
||||
return default
|
||||
|
||||
|
||||
def _get_pr_number():
|
||||
"""从环境变量或事件文件中获取 PR 号"""
|
||||
# 直接从环境变量
|
||||
pr = os.getenv("PR_NUMBER", "") or os.getenv("GITEA_PR_NUMBER", "")
|
||||
if pr:
|
||||
return pr
|
||||
|
||||
# 从 GITHUB_EVENT_PATH 读取
|
||||
event_path = os.getenv("GITHUB_EVENT_PATH", "") or os.getenv("GITEA_EVENT_PATH", "")
|
||||
if event_path and os.path.isfile(event_path):
|
||||
try:
|
||||
with open(event_path, "r") as f:
|
||||
event = json.load(f)
|
||||
if "pull_request" in event and "number" in event["pull_request"]:
|
||||
return str(event["pull_request"]["number"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _get_ci_attributes():
|
||||
"""从 CI 环境变量中收集属性"""
|
||||
attrs = {
|
||||
"ci.repo": _env("GITEA_REPOSITORY") or _env("GITHUB_REPOSITORY") or "unknown",
|
||||
"ci.workflow": _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "unknown",
|
||||
"ci.job": _env("GITEA_JOB") or _env("GITHUB_JOB") or "unknown",
|
||||
"ci.commit_sha": _env("GITEA_SHA") or _env("GITHUB_SHA") or "unknown",
|
||||
"ci.branch": _env("GITEA_REF_NAME") or _env("GITHUB_REF_NAME") or "unknown",
|
||||
"ci.run_id": _env("GITEA_RUN_ID") or _env("GITHUB_RUN_ID") or "unknown",
|
||||
"ci.actor": _env("GITEA_ACTOR") or _env("GITHUB_ACTOR") or "unknown",
|
||||
"ci.event": _env("GITEA_EVENT_NAME") or _env("GITHUB_EVENT_NAME") or "unknown",
|
||||
}
|
||||
pr = _get_pr_number()
|
||||
if pr:
|
||||
attrs["ci.pr_number"] = pr
|
||||
return attrs
|
||||
|
||||
|
||||
# ========== 核心上报逻辑 ==========
|
||||
|
||||
def build_trace(service_name, trace_name, status, duration_ms, attributes=None):
|
||||
"""构建一条完整的 Trace 数据,返回 protobuf bytes。"""
|
||||
trace_id = _gen_trace_id()
|
||||
end_time = int(time.time() * 1e9)
|
||||
start_time = end_time - int(duration_ms * 1e6)
|
||||
status_code = 1 if status in ("ok", "running") else 2
|
||||
status_msg = "" if status in ("ok", "running") else "Job failed"
|
||||
|
||||
main_attrs = {
|
||||
"agent.trace_name": trace_name,
|
||||
"agent.service": service_name,
|
||||
"ci.trace_status": status,
|
||||
}
|
||||
if attributes:
|
||||
main_attrs.update(attributes)
|
||||
|
||||
main_span = _encode_span(
|
||||
trace_id_bytes=trace_id,
|
||||
span_id_bytes=_gen_span_id(),
|
||||
parent_span_id_bytes=b"",
|
||||
name=trace_name,
|
||||
start_time_unix_nano=start_time,
|
||||
end_time_unix_nano=end_time,
|
||||
span_kind=1,
|
||||
attributes=main_attrs,
|
||||
status_code=status_code,
|
||||
status_msg=status_msg,
|
||||
)
|
||||
|
||||
scope_spans = _encode_scope_spans("ci-trace", [main_span])
|
||||
resource_spans = _encode_resource_spans(service_name, scope_spans)
|
||||
return _encode_traces_data([resource_spans])
|
||||
|
||||
|
||||
def report_ci_trace(service_name, trace_name, status="ok", duration_ms=1000,
|
||||
endpoint=None, license_key=None, project=None, workspace=None,
|
||||
extra_attributes=None):
|
||||
"""
|
||||
上报 CI Trace 数据。返回 (success: bool, message: str)
|
||||
永远不会抛出异常,失败也返回 False。
|
||||
"""
|
||||
try:
|
||||
endpoint = endpoint or os.getenv("AGENTLOOP_ENDPOINT", DEFAULT_ENDPOINT)
|
||||
license_key = license_key or os.getenv("AGENTLOOP_LICENSE_KEY", "")
|
||||
project = project or os.getenv("AGENTLOOP_PROJECT", DEFAULT_PROJECT)
|
||||
workspace = workspace or os.getenv("AGENTLOOP_WORKSPACE", DEFAULT_WORKSPACE)
|
||||
|
||||
if not license_key:
|
||||
return False, "[Trace] 跳过上报:未配置 AGENTLOOP_LICENSE_KEY"
|
||||
|
||||
# 收集 CI 属性
|
||||
attrs = _get_ci_attributes()
|
||||
if extra_attributes:
|
||||
attrs.update(extra_attributes)
|
||||
|
||||
payload = build_trace(
|
||||
service_name=service_name,
|
||||
trace_name=trace_name,
|
||||
status=status,
|
||||
duration_ms=duration_ms,
|
||||
attributes=attrs,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-protobuf",
|
||||
"x-arms-license-key": license_key,
|
||||
"x-arms-project": project,
|
||||
"x-cms-workspace": workspace,
|
||||
}
|
||||
|
||||
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
status_code = resp.status
|
||||
resp_body = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
status_code = e.code
|
||||
resp_body = e.read().decode("utf-8", errors="replace")
|
||||
|
||||
if status_code in (200, 202):
|
||||
return True, f"[Trace] ✓ 上报成功: {service_name} / {trace_name} ({status}, {duration_ms}ms)"
|
||||
else:
|
||||
return False, f"[Trace] ⚠ 上报失败: HTTP {status_code} - {resp_body[:200]}"
|
||||
except Exception as e:
|
||||
return False, f"[Trace] ⚠ 上报异常: {type(e).__name__}: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AgentLoop Trace 上报")
|
||||
parser.add_argument("--service", dest="service_name",
|
||||
default=os.getenv("TRACE_SERVICE", ""),
|
||||
help="服务名称(也可通过 TRACE_SERVICE 环境变量设置)")
|
||||
parser.add_argument("--name", dest="trace_name",
|
||||
default=os.getenv("TRACE_NAME", ""),
|
||||
help="Trace 名称(也可通过 TRACE_NAME 环境变量设置)")
|
||||
parser.add_argument("--status", default=os.getenv("TRACE_STATUS", "ok"),
|
||||
choices=["ok", "error", "running"],
|
||||
help="状态: ok / error / running(默认 ok)")
|
||||
parser.add_argument("--start-time", dest="start_time",
|
||||
default=os.getenv("TRACE_START_TIME", ""),
|
||||
help="开始时间戳(秒),用于计算 duration;不填则用默认 1s")
|
||||
parser.add_argument("--duration-ms", dest="duration_ms", type=int, default=0,
|
||||
help="直接指定耗时(毫秒),优先级高于 --start-time")
|
||||
parser.add_argument("--attrs", default="",
|
||||
help="附加属性(JSON 字符串)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须参数检查
|
||||
if not args.service_name:
|
||||
print("[Trace] ⚠ 跳过上报:未指定 service(--service 或 TRACE_SERVICE)")
|
||||
sys.exit(0)
|
||||
|
||||
# 计算耗时
|
||||
duration_ms = args.duration_ms
|
||||
if duration_ms <= 0 and args.start_time:
|
||||
try:
|
||||
start_ts = float(args.start_time)
|
||||
duration_ms = int((time.time() - start_ts) * 1000)
|
||||
except (ValueError, TypeError):
|
||||
duration_ms = 1000
|
||||
if duration_ms <= 0:
|
||||
duration_ms = 1000 # 默认 1 秒
|
||||
|
||||
# 解析附加属性
|
||||
extra_attrs = {}
|
||||
if args.attrs:
|
||||
try:
|
||||
extra_attrs = json.loads(args.attrs)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 生成 trace_name:如果没指定,用 workflow+job
|
||||
trace_name = args.trace_name
|
||||
if not trace_name:
|
||||
wf = _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "CI"
|
||||
job = _env("GITEA_JOB") or _env("GITHUB_JOB") or "job"
|
||||
trace_name = f"{wf} / {job}"
|
||||
|
||||
success, msg = report_ci_trace(
|
||||
service_name=args.service_name,
|
||||
trace_name=trace_name,
|
||||
status=args.status,
|
||||
duration_ms=duration_ms,
|
||||
extra_attributes=extra_attrs,
|
||||
)
|
||||
|
||||
print(msg)
|
||||
# 永远 exit 0,不影响 CI
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,6 +17,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
@@ -172,3 +180,12 @@ jobs:
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -17,6 +17,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
@@ -172,3 +180,12 @@ jobs:
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -95,6 +95,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -144,6 +152,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Build Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: runtime-builder
|
||||
@@ -200,6 +217,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -302,6 +327,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: runtime-builder
|
||||
@@ -355,6 +389,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -382,6 +424,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: runtime-builder
|
||||
@@ -435,6 +486,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -462,6 +521,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
build-production:
|
||||
name: Build Production ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
@@ -536,6 +604,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -586,6 +662,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Build Production ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder
|
||||
@@ -642,6 +727,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -732,6 +825,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: runtime-builder
|
||||
@@ -745,6 +847,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"'
|
||||
Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -772,3 +882,102 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
acr-cleanup:
|
||||
name: ACR Image Cleanup
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- build-staging
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'INNERPY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Clean up old ACR images
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "Running ACR cleanup (keep 20 commit tags, PR tags keep 7 days)..."
|
||||
python3 scripts/ci/acr_cleanup.py --execute --keep 20 --pr-days 7
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="ACR Image Cleanup" python3 scripts/ci_notify.py
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
+102
-1
@@ -35,6 +35,14 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -63,6 +71,15 @@ jobs:
|
||||
echo "🔧 包含全栈变更,运行完整CI"
|
||||
fi
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -81,6 +98,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -233,6 +258,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -252,6 +286,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -425,6 +467,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
@@ -447,6 +498,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -544,6 +603,15 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-check
|
||||
@@ -555,6 +623,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -596,6 +672,15 @@ jobs:
|
||||
'
|
||||
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
frontend-unit-test:
|
||||
name: Frontend Unit Tests
|
||||
runs-on: ci-check
|
||||
@@ -608,6 +693,14 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -637,4 +730,12 @@ jobs:
|
||||
|
||||
NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
' - name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -22,6 +22,14 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
@@ -34,3 +42,12 @@ jobs:
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -25,6 +25,14 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
@@ -51,3 +59,12 @@ jobs:
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -64,6 +64,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
@@ -107,6 +115,15 @@ jobs:
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
@@ -161,6 +178,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
@@ -244,6 +269,15 @@ jobs:
|
||||
fi
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
@@ -298,6 +332,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
@@ -333,6 +375,15 @@ jobs:
|
||||
exit $EXIT_CODE
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
@@ -456,6 +507,14 @@ jobs:
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
@@ -581,6 +640,15 @@ jobs:
|
||||
fi
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
@@ -656,3 +724,20 @@ jobs:
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -61,6 +61,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Extract PR number
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -193,3 +201,12 @@ jobs:
|
||||
> /dev/null
|
||||
echo "Cleanup comment posted"
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -72,6 +72,14 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -272,3 +280,12 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -184,13 +184,19 @@ export const getAssetsByKind = async (
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string> = { kind }
|
||||
const params: Record<string, string | number> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
Regular → Executable
+92
-2
@@ -36,7 +36,13 @@ import {
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import "./voices.css"
|
||||
|
||||
@@ -45,7 +51,7 @@ import "./voices.css"
|
||||
* ============================================================ */
|
||||
type VoiceGender = "male" | "female" | "child" | "elderly"
|
||||
type VoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
type TabKey = "preset" | "cloned"
|
||||
type TabKey = "preset" | "cloned" | "material"
|
||||
|
||||
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
|
||||
interface PresetVoiceDisplay {
|
||||
@@ -798,6 +804,12 @@ const VoiceLibrary: React.FC = () => {
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
@@ -814,6 +826,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
)
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
@@ -984,6 +997,17 @@ const VoiceLibrary: React.FC = () => {
|
||||
我的克隆
|
||||
<span className="xx-voices-tab-count">{cloneCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("material")
|
||||
handlePause()
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
配音素材
|
||||
<span className="xx-voices-tab-count">{materialCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "preset" && (
|
||||
@@ -1133,6 +1157,72 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "material" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
{materialLoading && (
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||
<div className="vmat-thumb" />
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||
<div className="vmat-skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!materialLoading && (materialData?.length || 0) > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{(materialData || []).map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>
|
||||
{asset.file_size
|
||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!materialLoading && (materialData?.length || 0) === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>上传您的音频素材,用于视频配音</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
|
||||
<UploadOutlined /> 上传音频
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
Regular → Executable
+102
@@ -970,3 +970,105 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
配音素材卡片(与配音库Tab集成)
|
||||
================================================================ */
|
||||
|
||||
.vmat-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.vmat-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vmat-thumb-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.vmat-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vmat-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vmat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vmat-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vmat-card--skeleton .vmat-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.vmat-skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
animation: vmat-shimmer 1.5s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.vmat-skeleton-title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes vmat-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ module.exports = {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "@/api/auth"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
refreshAccessToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { baseURL: "/api/v1" },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
},
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -44,4 +75,192 @@ describe("normalizeUser", () => {
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers id over user_id when both present", () => {
|
||||
const result = normalizeUser({
|
||||
id: "id-first",
|
||||
user_id: "userid-second",
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.id).toBe("id-first")
|
||||
expect(result.user_id).toBe("id-first")
|
||||
})
|
||||
|
||||
it("prefers is_email_verified over email_verified", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
is_email_verified: true,
|
||||
email_verified: false,
|
||||
})
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
expect(result.email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults email verified to false when both missing", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.is_email_verified).toBe(false)
|
||||
expect(result.email_verified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth API functions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } })
|
||||
})
|
||||
|
||||
describe("login", () => {
|
||||
it("calls login API with correct params", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { access_token: "acc", refresh_token: "ref", user_id: "1" },
|
||||
})
|
||||
const result = await login({ email: "test@test.com", password: "pass" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/login", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
})
|
||||
expect(result.access_token).toBe("acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("login failed"))
|
||||
await expect(login({ email: "t", password: "p" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("calls register API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "ok" } })
|
||||
const result = await register({
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/register", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(result.message).toBe("ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("register failed"))
|
||||
await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout", () => {
|
||||
it("calls logout API", async () => {
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
await logout()
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/logout")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("logout failed"))
|
||||
await expect(logout()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCurrentUser", () => {
|
||||
it("fetches and normalizes user", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "u1",
|
||||
email: "user@test.com",
|
||||
username: "user1",
|
||||
display_name: "User One",
|
||||
email_verified: true,
|
||||
},
|
||||
})
|
||||
const result = await getCurrentUser()
|
||||
expect(mockGet).toHaveBeenCalledWith("/auth/me")
|
||||
expect(result.id).toBe("u1")
|
||||
expect(result.email).toBe("user@test.com")
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("fetch failed"))
|
||||
await expect(getCurrentUser()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("refreshAccessToken", () => {
|
||||
it("calls refresh endpoint with raw axios", async () => {
|
||||
mockAxiosPost.mockResolvedValue({
|
||||
data: { access_token: "new-acc", refresh_token: "new-ref" },
|
||||
})
|
||||
const result = await refreshAccessToken("old-refresh")
|
||||
expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", {
|
||||
refresh_token: "old-refresh",
|
||||
})
|
||||
expect(result.access_token).toBe("new-acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockAxiosPost.mockRejectedValue(new Error("refresh failed"))
|
||||
await expect(refreshAccessToken("tok")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestPasswordReset", () => {
|
||||
it("calls forgot-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "sent" } })
|
||||
const result = await requestPasswordReset("test@test.com")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", {
|
||||
email: "test@test.com",
|
||||
})
|
||||
expect(result.message).toBe("sent")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(requestPasswordReset("e")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("calls reset-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "reset ok" } })
|
||||
const result = await resetPassword("token123", "newpass")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", {
|
||||
token: "token123",
|
||||
new_password: "newpass",
|
||||
})
|
||||
expect(result.message).toBe("reset ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(resetPassword("t", "p")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("verifyEmail", () => {
|
||||
it("calls verify-email API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "verified" } })
|
||||
const result = await verifyEmail("verify-token")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", {
|
||||
token: "verify-token",
|
||||
})
|
||||
expect(result.message).toBe("verified")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("verify failed"))
|
||||
await expect(verifyEmail("t")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: {
|
||||
getState: vi.fn(() => ({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
|
||||
fulfilled: (config: unknown) => unknown
|
||||
rejected: (error: unknown) => unknown
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
|
||||
fulfilled: (response: unknown) => unknown
|
||||
rejected: (error: unknown) => Promise<unknown>
|
||||
}>
|
||||
|
||||
const requestInterceptor = requestHandlers[0]?.fulfilled!
|
||||
const requestErrorInterceptor = requestHandlers[0]?.rejected!
|
||||
const responseInterceptor = responseHandlers[0]?.fulfilled!
|
||||
const responseErrorInterceptor = responseHandlers[0]?.rejected!
|
||||
|
||||
function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) {
|
||||
const err = {
|
||||
config: { headers: {} },
|
||||
message: "error",
|
||||
} as {
|
||||
config: { headers: Record<string, string>; _retry?: boolean; url?: string }
|
||||
response?: { status: number; data: unknown }
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
if (hasResponse && status !== undefined) {
|
||||
err.response = { status, data }
|
||||
}
|
||||
if (code) err.code = code
|
||||
return err
|
||||
}
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("request interceptor", () => {
|
||||
it("adds Authorization header when token exists", () => {
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBe("Bearer test-token")
|
||||
})
|
||||
|
||||
it("skips Authorization header when no token", () => {
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects on request error", async () => {
|
||||
const error = new Error("request error")
|
||||
await expect(requestErrorInterceptor(error) as Promise<never>).rejects.toThrow(
|
||||
"request error",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - success", () => {
|
||||
it("passes through successful response", () => {
|
||||
const response = { data: { success: true }, status: 200 }
|
||||
expect(responseInterceptor(response)).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - timeout & network", () => {
|
||||
it("shows timeout message for ECONNABORTED", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, "ECONNABORTED")
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows timeout message for timeout string", async () => {
|
||||
const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" }
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows network error when no response", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, undefined, false)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置")
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - server error messages", () => {
|
||||
it("shows detail field", async () => {
|
||||
const err = makeAxiosError(400, { detail: "参数错误" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("参数错误")
|
||||
})
|
||||
|
||||
it("shows message field", async () => {
|
||||
const err = makeAxiosError(400, { message: "操作失败" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("操作失败")
|
||||
})
|
||||
|
||||
it("shows msg field", async () => {
|
||||
const err = makeAxiosError(400, { msg: "出错了" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("出错了")
|
||||
})
|
||||
|
||||
it("handles nested message object", async () => {
|
||||
const err = makeAxiosError(400, { message: { message: "深层错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("深层错误")
|
||||
})
|
||||
|
||||
it("handles nested msg object", async () => {
|
||||
const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("嵌套错误")
|
||||
})
|
||||
|
||||
it("stringifies object with no string fields", async () => {
|
||||
const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}')
|
||||
})
|
||||
|
||||
it("marks __msgShown when message displayed", async () => {
|
||||
const err = makeAxiosError(400, { detail: "test" }) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: { detail: string } }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - HTTP status codes", () => {
|
||||
it("shows file too large for 413", async () => {
|
||||
const err = makeAxiosError(413, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试")
|
||||
})
|
||||
|
||||
it("shows unsupported format for 415", async () => {
|
||||
const err = makeAxiosError(415, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("不支持的文件格式")
|
||||
})
|
||||
|
||||
it("shows service unavailable for 503", async () => {
|
||||
const err = makeAxiosError(503, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 500", async () => {
|
||||
const err = makeAxiosError(500, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 502", async () => {
|
||||
const err = makeAxiosError(502, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("no message for 4xx without server msg", async () => {
|
||||
const err = makeAxiosError(403, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("no __msgShown for unhandled 4xx", async () => {
|
||||
const err = makeAxiosError(403, {}) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: Record<string, never> }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("safeExtractString edge cases", () => {
|
||||
it("returns empty string for numeric message", async () => {
|
||||
const err = makeAxiosError(400, { message: 123 })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns empty string for null data", async () => {
|
||||
const err = makeAxiosError(400, null)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles detail with nested detail object", async () => {
|
||||
const err = makeAxiosError(400, { detail: { detail: "nested detail" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("nested detail")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiClient - 401 token refresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem("access_token", "old-access")
|
||||
localStorage.setItem("refresh_token", "old-refresh")
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("logs out when no refresh token on 401", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
|
||||
it("refreshes token on 401 and calls setAuth", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="page-head">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
Select: () => <select />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Empty: () => <div>Empty</div>,
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: ({ children }: any) => <div>{children}</div>,
|
||||
Progress: () => <div />,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Table: () => <div />,
|
||||
Pagination: () => <div />,
|
||||
Tabs: () => <div />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Popconfirm: ({ children }: any) => <span>{children}</span>,
|
||||
Form: ({ children }: any) => <form>{children}</form>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
InputNumber: () => <input type="number" />,
|
||||
Select: () => <select />,
|
||||
Empty: () => <div>Empty</div>,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Badge: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: { Dragger: ({ children }: any) => <div>{children}</div> },
|
||||
Progress: () => <div />,
|
||||
Switch: () => <input type="checkbox" />,
|
||||
Radio: ({ children }: any) => <span>{children}</span>,
|
||||
RadioGroup: ({ children }: any) => <div>{children}</div>,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Popover: ({ children }: any) => <span>{children}</span>,
|
||||
Divider: () => <hr />,
|
||||
Dropdown: ({ children }: any) => <span>{children}</span>,
|
||||
Menu: () => <div />,
|
||||
Checkbox: ({ children }: any) => <span>{children}</span>,
|
||||
List: () => <div />,
|
||||
Avatar: ({ children }: any) => <span>{children}</span>,
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Result: ({ status, title }: any) => <div data-status={status}>{title}</div>,
|
||||
Spin: () => <div>Loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
PlusOutlined: () => <span>+</span>,
|
||||
SearchOutlined: () => <span>S</span>,
|
||||
InboxOutlined: () => <span>I</span>,
|
||||
VideoCameraOutlined: () => <span>V</span>,
|
||||
PictureOutlined: () => <span>P</span>,
|
||||
PlayCircleOutlined: () => <span>▶</span>,
|
||||
CheckOutlined: () => <span>✓</span>,
|
||||
DeleteOutlined: () => <span>×</span>,
|
||||
ExperimentOutlined: () => <span>E</span>,
|
||||
LoadingOutlined: () => <span>L</span>,
|
||||
ExclamationCircleOutlined: () => <span>!</span>,
|
||||
TagsOutlined: () => <span>T</span>,
|
||||
EditOutlined: () => <span>E</span>,
|
||||
DownloadOutlined: () => <span>D</span>,
|
||||
MoreOutlined: () => <span>M</span>,
|
||||
FolderOutlined: () => <span>F</span>,
|
||||
FolderAddOutlined: () => <span>FA</span>,
|
||||
UploadOutlined: () => <span>U</span>,
|
||||
AudioOutlined: () => <span>A</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
getAssetDiagnosis: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
batchClassifyAssets: vi.fn().mockResolvedValue({}),
|
||||
batchMarkAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
import AssetLibrary from "@/pages/assets/AssetLibrary"
|
||||
|
||||
describe("AssetLibrary", () => {
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows empty state when no assets", () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 空状态文案应该出现
|
||||
expect(getByText(/暂无素材/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Regular → Executable
+16
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act, cleanup } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
@@ -13,12 +13,25 @@ vi.mock("@/components/layout/PageHead", () => ({
|
||||
import Billing from "@/pages/subscription/Billing"
|
||||
|
||||
describe("Billing Page", () => {
|
||||
it("should render without crashing", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should render without crashing", async () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<Billing />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 跑完所有pending的timers和microtasks,确保异步状态更新都执行完
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
VideoCameraOutlined: () => React.createElement("span", null, "V"),
|
||||
PictureOutlined: () => React.createElement("span", null, "P"),
|
||||
SoundOutlined: () => React.createElement("span", null, "S"),
|
||||
PlusOutlined: () => React.createElement("span", null, "+"),
|
||||
DeleteOutlined: () => React.createElement("span", null, "D"),
|
||||
EditOutlined: () => React.createElement("span", null, "E"),
|
||||
CopyOutlined: () => React.createElement("span", null, "C"),
|
||||
DownloadOutlined: () => React.createElement("span", null, "D"),
|
||||
PlayCircleOutlined: () => React.createElement("span", null, ">"),
|
||||
PauseCircleOutlined: () => React.createElement("span", null, "||"),
|
||||
LeftOutlined: () => React.createElement("span", null, "<"),
|
||||
RightOutlined: () => React.createElement("span", null, ">"),
|
||||
UpOutlined: () => React.createElement("span", null, "^"),
|
||||
DownOutlined: () => React.createElement("span", null, "v"),
|
||||
SaveOutlined: () => React.createElement("span", null, "S"),
|
||||
UndoOutlined: () => React.createElement("span", null, "U"),
|
||||
RedoOutlined: () => React.createElement("span", null, "R"),
|
||||
CloseOutlined: () => React.createElement("span", null, "X"),
|
||||
CheckOutlined: () => React.createElement("span", null, "v"),
|
||||
SettingOutlined: () => React.createElement("span", null, "S"),
|
||||
AppstoreOutlined: () => React.createElement("span", null, "#"),
|
||||
UnorderedListOutlined: () => React.createElement("span", null, "="),
|
||||
HistoryOutlined: () => React.createElement("span", null, "H"),
|
||||
UploadOutlined: () => React.createElement("span", null, "U"),
|
||||
SearchOutlined: () => React.createElement("span", null, "S"),
|
||||
FilterOutlined: () => React.createElement("span", null, "F"),
|
||||
FontColorsOutlined: () => React.createElement("span", null, "A"),
|
||||
BgColorsOutlined: () => React.createElement("span", null, "B"),
|
||||
AudioOutlined: () => React.createElement("span", null, "A"),
|
||||
MusicOutlined: () => React.createElement("span", null, "M"),
|
||||
ScissorOutlined: () => React.createElement("span", null, "X"),
|
||||
ThunderboltOutlined: () => React.createElement("span", null, "T"),
|
||||
ExperimentOutlined: () => React.createElement("span", null, "E"),
|
||||
BulbOutlined: () => React.createElement("span", null, "B"),
|
||||
FundOutlined: () => React.createElement("span", null, "F"),
|
||||
LayoutOutlined: () => React.createElement("span", null, "L"),
|
||||
ColumnHeightOutlined: () => React.createElement("span", null, "C"),
|
||||
SwapOutlined: () => React.createElement("span", null, "S"),
|
||||
}))
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Tabs: ({ items }: any) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
items?.map?.(() => React.createElement("div")),
|
||||
),
|
||||
TabPane: () => React.createElement("div"),
|
||||
Drawer: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Slider: () => React.createElement("div"),
|
||||
ColorPicker: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Row: ({ children }: any) => React.createElement("div", null, children),
|
||||
Col: ({ children }: any) => React.createElement("div", null, children),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
createEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
updateEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
getTemplateCategories: vi.fn().mockResolvedValue({ items: [] }),
|
||||
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
cancelGeneration: vi.fn().mockResolvedValue({}),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "default-lib" }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "default-project" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/bgm", () => ({
|
||||
DEFAULT_BGM_MIX_CONFIG: { volume: 1, fade_in: 0, fade_out: 0 },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/components/MediaPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "MediaPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PreviewPlayer", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PreviewPlayer" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TimelinePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TimelinePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/ClipPropertiesPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "ClipPropertiesPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/EditorClipList", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "EditorClipList" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/BgmSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "BgmSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SubtitleStylePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SubtitleStylePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TransitionSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TransitionSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SpeedPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SpeedPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TtsPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TtsPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/WatermarkPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "WatermarkPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/IntroOutroPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "IntroOutroPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PipConfigPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PipConfigPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/FilterPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "FilterPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GreenScreenPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
|
||||
}))
|
||||
|
||||
// === useUndoRedo hook mock ===
|
||||
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn((initial: any) => ({
|
||||
state: initial,
|
||||
setState: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Types mock ===
|
||||
vi.mock("@/pages/editing-planner/types", () => ({
|
||||
DEFAULT_TRANSITION: { type: "fade", duration: 0.5 },
|
||||
DEFAULT_SPEED: { rate: 1 },
|
||||
DEFAULT_TTS_CONFIG: { enabled: false },
|
||||
DEFAULT_WATERMARK: { enabled: false },
|
||||
DEFAULT_INTRO_OUTRO: { enabled: false },
|
||||
DEFAULT_PIP_CONFIG: { enabled: false },
|
||||
DEFAULT_FILTER_CONFIG: { enabled: false },
|
||||
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
|
||||
DEFAULT_STICKER_CONFIG: { enabled: false },
|
||||
DEFAULT_COVER_CONFIG: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
|
||||
DEFAULT_SUBTITLE_STYLE: {
|
||||
font_size: 24,
|
||||
font_color: "#ffffff",
|
||||
background_color: "#000000",
|
||||
},
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import EditingPlanner from "@/pages/editing-planner/EditingPlanner"
|
||||
|
||||
describe("EditingPlanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with templateId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?templateId=tpl-123"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with planId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?planId=plan-456"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"CheckCircleFilled",
|
||||
"CheckCircleOutlined",
|
||||
"CloseCircleOutlined",
|
||||
"LoadingOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"ShareAltOutlined",
|
||||
"SaveOutlined",
|
||||
"PlusOutlined",
|
||||
"MinusOutlined",
|
||||
"CloseOutlined",
|
||||
"SearchOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => {
|
||||
const Typography = {
|
||||
Text: ({ children }: any) => React.createElement("span", null, children),
|
||||
Title: ({ children }: any) => React.createElement("h1", null, children),
|
||||
Paragraph: ({ children }: any) => React.createElement("p", null, children),
|
||||
}
|
||||
return {
|
||||
Typography,
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) =>
|
||||
React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
Steps: () => React.createElement("div"),
|
||||
Step: () => React.createElement("div"),
|
||||
}
|
||||
})
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
MODE_LABELS: { pip: "画中画" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/titles", () => ({
|
||||
getTitles: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
formatDuration: vi.fn((s: number) => `${s}s`),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
interceptors: { request: { handlers: [] }, response: { handlers: [] } },
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
put: vi.fn().mockResolvedValue({ data: {} }),
|
||||
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}))
|
||||
|
||||
// === Hooks mock ===
|
||||
vi.mock("@/hooks/useCloneProgress", () => ({
|
||||
useCloneProgress: vi.fn(() => ({
|
||||
progress: 0,
|
||||
status: "idle",
|
||||
start: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Components mock ===
|
||||
vi.mock("@/components/voice/CloneModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CloneModal" }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
// CSS mock
|
||||
vi.mock("@/pages/generate/generate.css", () => ({}))
|
||||
|
||||
import GeneratePage from "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GeneratePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(GeneratePage)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(GeneratePage)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory - must be before all vi.mock calls
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"SearchOutlined",
|
||||
"PlusOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"CloseOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"CheckOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"LoadingOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SaveOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import VoiceMaterialLibrary from "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
|
||||
describe("VoiceMaterialLibrary", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,493 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
onSuccess: undefined,
|
||||
onError: undefined,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
deleteEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
reorderEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({ deleted_count: 0 }),
|
||||
createClipsFromAssets: vi.fn().mockResolvedValue({ created_count: 0 }),
|
||||
}))
|
||||
|
||||
vi.mock("./useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn(() => ({
|
||||
state: [],
|
||||
set: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
import { useEditPlanClips } from "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
|
||||
describe("useEditPlanClips", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns default state with planId", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toEqual([])
|
||||
expect(result.current.clipsTotal).toBe(0)
|
||||
expect(result.current.clipsLoading).toBe(false)
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("returns clips from query data", () => {
|
||||
const mockClips = [
|
||||
{ id: "clip-1", type: "video", asset_id: "a1", order: 0 },
|
||||
{ id: "clip-2", type: "video", asset_id: "a2", order: 1 },
|
||||
]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 2 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toHaveLength(2)
|
||||
expect(result.current.clipsTotal).toBe(2)
|
||||
expect(result.current.clips[0].id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("handles loading state", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clipsLoading).toBe(true)
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("disables query when no planId", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("setSelectedClipId updates selection", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip finds matching clip", () => {
|
||||
const mockClips = [{ id: "clip-1", type: "video", order: 0 }]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip?.id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip returns null when no match", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [{ id: "c1", order: 0 }], total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("nonexistent")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("addClip calls createMutation with order", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => {
|
||||
// 捕获 onSuccess/onError 回调
|
||||
return {
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("removeClip calls deleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith("clip-1")
|
||||
})
|
||||
|
||||
it("removeClip clears selection if selected clip is deleted", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("removeClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("updateClip calls updateMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("clip-1", { duration: 10 })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
clipId: "clip-1",
|
||||
data: { duration: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it("updateClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("c1", {})
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips calls batchDeleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
it("batchRemoveClips does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips clears selection if selected is in batch", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("reorderClips calls reorderMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
it("reorderClips does nothing with empty items", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets calls importMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["a1"])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns local undo redo state", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.localClips).toEqual([])
|
||||
expect(typeof result.current.setLocalClips).toBe("function")
|
||||
expect(typeof result.current.undo).toBe("function")
|
||||
expect(typeof result.current.redo).toBe("function")
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
expect(typeof result.current.resetLocalClips).toBe("function")
|
||||
})
|
||||
|
||||
it("exposes mutation status flags", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.isCreating).toBe(false)
|
||||
expect(result.current.isUpdating).toBe(false)
|
||||
expect(result.current.isDeleting).toBe(false)
|
||||
expect(result.current.isReordering).toBe(false)
|
||||
expect(result.current.isImporting).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
DEFAULT_SUBTITLE_STYLE,
|
||||
type SubtitleStyleConfig,
|
||||
type SubtitleMode,
|
||||
} from "@/pages/editing-planner/types/subtitle"
|
||||
|
||||
describe("subtitle types & defaults", () => {
|
||||
it("DEFAULT_SUBTITLE_STYLE has correct shape", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE).toMatchObject<SubtitleStyleConfig>({
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
})
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE enabled is boolean", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.enabled).toBe("boolean")
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE fontSize is number", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.fontSize).toBe("number")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontSize).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE position is valid", () => {
|
||||
expect(["top", "center", "bottom"]).toContain(DEFAULT_SUBTITLE_STYLE.position)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE mode is valid SubtitleMode", () => {
|
||||
const mode: SubtitleMode = DEFAULT_SUBTITLE_STYLE.mode
|
||||
expect(["manual", "asr"]).toContain(mode)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE asrLanguage is valid", () => {
|
||||
expect(["zh", "en"]).toContain(DEFAULT_SUBTITLE_STYLE.asrLanguage)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE has all required fields", () => {
|
||||
const keys = Object.keys(DEFAULT_SUBTITLE_STYLE)
|
||||
expect(keys.length).toBeGreaterThanOrEqual(10)
|
||||
expect(keys).toContain("enabled")
|
||||
expect(keys).toContain("mode")
|
||||
expect(keys).toContain("fontSize")
|
||||
expect(keys).toContain("fontColor")
|
||||
expect(keys).toContain("stroke")
|
||||
expect(keys).toContain("shadow")
|
||||
expect(keys).toContain("position")
|
||||
expect(keys).toContain("font")
|
||||
expect(keys).toContain("animation")
|
||||
expect(keys).toContain("asrLanguage")
|
||||
})
|
||||
|
||||
it("fontColor is valid hex color", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontColor).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
})
|
||||
|
||||
it("animation is string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.animation).toBe("string")
|
||||
})
|
||||
|
||||
it("font is non-empty string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.font).toBe("string")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.font.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: vi.fn((selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/home/HomePage", () => ({
|
||||
default: () => <div data-testid="home-page">Home</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/layout/MainLayout", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="main-layout">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { router } from "@/router"
|
||||
|
||||
// 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致)
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const HomeRoute = () => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (isAuthenticated && hasAccessToken) {
|
||||
return <Navigate to="/app/dashboard" replace />
|
||||
}
|
||||
|
||||
return <div data-testid="home-page">Home</div>
|
||||
}
|
||||
|
||||
describe("router - ProtectedRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("redirects to login when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to login when authenticated but no token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.removeItem("access_token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders children when authenticated and has token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("protected")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router - HomeRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("shows home page when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to dashboard when authenticated with token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("dashboard")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows home when authenticated but no localStorage token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router config", () => {
|
||||
it("exports router", () => {
|
||||
expect(router).toBeDefined()
|
||||
})
|
||||
|
||||
it("router has correct number of top-level routes", () => {
|
||||
const routes = router.routes
|
||||
expect(Array.isArray(routes)).toBe(true)
|
||||
expect(routes.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it("includes login route", () => {
|
||||
const loginRoute = router.routes.find((r: any) => r.path === "/login")
|
||||
expect(loginRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes register route", () => {
|
||||
const registerRoute = router.routes.find((r: any) => r.path === "/register")
|
||||
expect(registerRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes root route", () => {
|
||||
const rootRoute = router.routes.find((r: any) => r.path === "/")
|
||||
expect(rootRoute).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -16,19 +16,7 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
"src/test/",
|
||||
"e2e/",
|
||||
"**/*.d.ts",
|
||||
"**/*.config.*",
|
||||
"**/mockData",
|
||||
// 页面级组件不纳入单测覆盖率统计(页面级走 E2E/手动测试)
|
||||
"src/pages/generate/GeneratePage.tsx",
|
||||
"src/pages/editing-planner/EditingPlanner.tsx",
|
||||
"src/pages/assets/AssetLibrary.tsx",
|
||||
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
|
||||
],
|
||||
exclude: ["node_modules/", "src/test/", "e2e/", "**/*.d.ts", "**/*.config.*", "**/mockData"],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
thresholds: {
|
||||
|
||||
@@ -142,19 +142,20 @@ def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
storage_key: 目标存储键
|
||||
|
||||
Returns:
|
||||
公开访问 URL,上传失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
|
||||
@@ -38,53 +38,36 @@ def extract_first_frame(
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
_is_temp_output = False
|
||||
if output_path is None:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
output_path = tmp.name
|
||||
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
seek_str,
|
||||
"-i",
|
||||
video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
_is_temp_output = True
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 失败时退回到第0帧兜底
|
||||
cmd2 = [
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
seek_str,
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
@@ -93,12 +76,40 @@ def extract_first_frame(
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 失败时退回到第0帧兜底
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
return output_path
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
# 失败时清理自己创建的临时文件
|
||||
if _is_temp_output and output_path:
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _format_seek_time(seconds: float) -> str:
|
||||
|
||||
@@ -280,6 +280,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 先从 OSS 下载文件到本地临时目录,再提取元数据
|
||||
# (storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow)
|
||||
local_file = None
|
||||
thumbnail_url = None
|
||||
try:
|
||||
suffix = Path(job.storage_key).suffix or ".bin"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
@@ -291,6 +292,27 @@ def ingest_asset(job_id: str) -> dict:
|
||||
metadata, extract_success = {}, False
|
||||
else:
|
||||
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
|
||||
|
||||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||||
thumbnail_url = None
|
||||
if media_type == "video" and extract_success:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
|
||||
if thumbnail_url:
|
||||
logger.info(
|
||||
"素材缩略图生成成功: job_id=%s url=%s",
|
||||
job_id,
|
||||
thumbnail_url[:80],
|
||||
)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
|
||||
job_id,
|
||||
thumb_err,
|
||||
)
|
||||
finally:
|
||||
if local_file and local_file.exists():
|
||||
try:
|
||||
@@ -359,6 +381,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
codec=metadata.get("codec") or None,
|
||||
status=AssetStatus.READY,
|
||||
file_hash=job.file_hash,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class Asset:
|
||||
storage_key=storage_key.strip(),
|
||||
mime_type=mime_type.strip(),
|
||||
file_size=file_size,
|
||||
thumbnail_url=thumbnail_url,
|
||||
thumbnail_url=str(thumbnail_url) if thumbnail_url else None,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACR 镜像清理脚本
|
||||
策略:
|
||||
- 版本tag (v*): 永久保留
|
||||
- 固定tag (latest, main, develop, master): 永久保留
|
||||
- 缓存镜像 (*-cache): 永久保留
|
||||
- PR预览tag (pr-*): 保留 N 天(默认7天)
|
||||
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
|
||||
|
||||
使用方式:
|
||||
python3 acr_cleanup.py --dry-run # 预览,不实际删除
|
||||
python3 acr_cleanup.py --execute # 实际执行删除
|
||||
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 配置
|
||||
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
|
||||
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
|
||||
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
|
||||
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
|
||||
USERNAME = os.environ.get("ACR_USERNAME", "")
|
||||
PASSWORD = os.environ.get("ACR_PASSWORD", "")
|
||||
|
||||
REPOS = [
|
||||
"xiaoxia-saas-api",
|
||||
"xiaoxia-saas-worker",
|
||||
"xiaoxia-saas-web",
|
||||
"api-cache",
|
||||
"worker-cache",
|
||||
"web-cache",
|
||||
]
|
||||
|
||||
# 缓存镜像仓库(所有tag永久保留)
|
||||
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
|
||||
|
||||
# OCI / Docker manifest types
|
||||
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
|
||||
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
|
||||
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
|
||||
def get_token(repo, action="pull"):
|
||||
"""获取仓库访问token"""
|
||||
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
|
||||
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
|
||||
req = urllib.request.Request(token_url)
|
||||
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("token", "")
|
||||
|
||||
|
||||
def get_tags(repo, token):
|
||||
"""获取仓库所有tag"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("tags", []) or []
|
||||
|
||||
|
||||
def http_get_json(url, token, accept_header):
|
||||
"""带Authorization的GET请求,返回(json_data, headers)"""
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", accept_header)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read()), resp.headers
|
||||
|
||||
|
||||
def get_manifest_info(repo, tag, token):
|
||||
"""
|
||||
获取tag的manifest信息,支持OCI index和普通manifest两种格式。
|
||||
返回: {digest, created, media_type}
|
||||
- digest: 顶层manifest的digest(用于删除)
|
||||
- created: 镜像创建时间
|
||||
"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
|
||||
result = {"digest": "", "created": "", "media_type": "", "error": ""}
|
||||
|
||||
# 先尝试 OCI index 格式(ACR多用这种)
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_INDEX)
|
||||
top_digest = headers.get("Docker-Content-Digest", "")
|
||||
result["digest"] = top_digest
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
|
||||
|
||||
# OCI index:找amd64的manifest,再取config blob
|
||||
manifests = data.get("manifests", [])
|
||||
amd64_manifest = None
|
||||
for m in manifests:
|
||||
arch = m.get("platform", {}).get("architecture", "")
|
||||
if arch == "amd64":
|
||||
amd64_manifest = m
|
||||
break
|
||||
# 没有amd64就用第一个
|
||||
if not amd64_manifest and manifests:
|
||||
amd64_manifest = manifests[0]
|
||||
|
||||
if amd64_manifest:
|
||||
inner_digest = amd64_manifest["digest"]
|
||||
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
|
||||
try:
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
|
||||
except Exception:
|
||||
# 退而求其次用v2格式
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
|
||||
|
||||
config_digest = inner_data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 再尝试普通 OCI manifest 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 最后试 Docker v2 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def delete_manifest(repo, digest, token):
|
||||
"""按digest删除manifest(会级联删除所有指向它的tag)"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
|
||||
req = urllib.request.Request(url, method="DELETE")
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", ACCEPT_INDEX)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_V2)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return True, resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
|
||||
def parse_time(created_str):
|
||||
"""解析ISO时间字符串"""
|
||||
if not created_str:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
if created_str.endswith("Z"):
|
||||
created_str = created_str[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(created_str)
|
||||
except Exception:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def is_version_tag(tag):
|
||||
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
|
||||
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
|
||||
|
||||
|
||||
def is_fixed_tag(tag):
|
||||
"""判断是否是固定tag"""
|
||||
return tag in ("latest", "main", "develop", "master", "dev", "stable")
|
||||
|
||||
|
||||
def is_pr_tag(tag):
|
||||
"""判断是否是PR预览tag"""
|
||||
return tag.startswith("pr-")
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
"""清理单个仓库"""
|
||||
print("=" * 60)
|
||||
print("仓库:", repo)
|
||||
print("=" * 60)
|
||||
|
||||
# 缓存仓库不清理
|
||||
if repo in CACHE_REPOS:
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
|
||||
return len(tags), 0
|
||||
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 总tag数:", len(tags))
|
||||
|
||||
# 分类
|
||||
version_tags = []
|
||||
fixed_tags = []
|
||||
pr_tags_list = []
|
||||
commit_tags = []
|
||||
|
||||
for tag in tags:
|
||||
if is_version_tag(tag):
|
||||
version_tags.append(tag)
|
||||
elif is_fixed_tag(tag):
|
||||
fixed_tags.append(tag)
|
||||
elif is_pr_tag(tag):
|
||||
pr_tags_list.append(tag)
|
||||
else:
|
||||
commit_tags.append(tag)
|
||||
|
||||
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
|
||||
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
|
||||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天")
|
||||
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个")
|
||||
|
||||
# 获取所有commit tag的创建时间
|
||||
print()
|
||||
print(" 获取commit tag创建时间...")
|
||||
tag_info_list = []
|
||||
errors = 0
|
||||
for i, tag in enumerate(commit_tags):
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["error"] or not info["digest"]:
|
||||
errors += 1
|
||||
# 取不到信息的tag,放到最后(最旧处理),但标记一下
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
|
||||
else:
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
|
||||
if (i + 1) % 20 == 0:
|
||||
print(" 已获取", i + 1, "/", len(commit_tags), "...")
|
||||
|
||||
if errors:
|
||||
print(" 注意:", errors, "个tag获取manifest失败")
|
||||
|
||||
# 按时间倒序排序(空时间放最后)
|
||||
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||||
|
||||
# 确定要删除的commit tag
|
||||
to_delete = []
|
||||
if len(tag_info_list) > keep_count:
|
||||
to_delete = tag_info_list[keep_count:]
|
||||
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "个")
|
||||
# 打印保留范围
|
||||
kept = tag_info_list[:keep_count]
|
||||
valid_kept = [t for t in kept if t["created"]]
|
||||
if valid_kept:
|
||||
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
|
||||
to_del_valid = [t for t in to_delete if t["digest"]]
|
||||
print(" 可删除(有digest):", len(to_del_valid), "个")
|
||||
else:
|
||||
print(" commit tag数量不足", keep_count, ",无需清理")
|
||||
|
||||
# PR tag按时间清理
|
||||
pr_to_delete = []
|
||||
if pr_tags_list:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||||
print()
|
||||
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
|
||||
for tag in pr_tags_list:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
print(" PR tag将删除:", len(pr_to_delete), "个")
|
||||
|
||||
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
|
||||
|
||||
if not all_to_delete:
|
||||
print()
|
||||
print(" 无需删除任何tag")
|
||||
return len(tags), 0
|
||||
|
||||
# 执行删除
|
||||
print()
|
||||
if dry_run:
|
||||
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
|
||||
# 去重digest
|
||||
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
|
||||
print(" 去重后唯一digest数:", len(unique_digests))
|
||||
for item in all_to_delete[:5]:
|
||||
created_str = item.get("created", "")[:10] or "未知"
|
||||
print(" -", item["tag"][:20], "(" + created_str + ")")
|
||||
if len(all_to_delete) > 5:
|
||||
print(" ... 还有", len(all_to_delete) - 5, "个")
|
||||
return len(tags), len(unique_digests)
|
||||
|
||||
token_delete = get_token(repo, "delete")
|
||||
deleted = 0
|
||||
failed = 0
|
||||
# 按digest去重,避免重复删除同一镜像
|
||||
seen_digests = set()
|
||||
unique_delete = []
|
||||
for item in all_to_delete:
|
||||
if item["digest"] and item["digest"] not in seen_digests:
|
||||
seen_digests.add(item["digest"])
|
||||
unique_delete.append(item)
|
||||
|
||||
print(" 开始删除", len(unique_delete), "个唯一manifest...")
|
||||
for item in unique_delete:
|
||||
success, result = delete_manifest(repo, item["digest"], token_delete)
|
||||
if success:
|
||||
deleted += 1
|
||||
print(" 已删除:", item["tag"][:20])
|
||||
else:
|
||||
failed += 1
|
||||
print(" 删除失败:", item["tag"][:20], "-", result)
|
||||
|
||||
print()
|
||||
print(" 删除完成: 成功", deleted, "个,失败", failed, "个")
|
||||
return len(tags), deleted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
|
||||
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20)")
|
||||
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
|
||||
parser.add_argument("--execute", action="store_true", help="实际执行删除")
|
||||
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须指定 --dry-run 或 --execute
|
||||
if not args.dry_run and not args.execute:
|
||||
print("请指定 --dry-run(预览)或 --execute(执行)")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
|
||||
print(" python3 acr_cleanup.py --execute # 实际执行清理")
|
||||
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
|
||||
sys.exit(1)
|
||||
|
||||
# 凭证检查
|
||||
global USERNAME, PASSWORD
|
||||
if not USERNAME or not PASSWORD:
|
||||
# 尝试从docker config读取
|
||||
try:
|
||||
docker_config_path = os.path.expanduser("~/.docker/config.json")
|
||||
with open(docker_config_path) as f:
|
||||
config = json.load(f)
|
||||
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
|
||||
if auth:
|
||||
creds = base64.b64decode(auth).decode().strip()
|
||||
USERNAME, PASSWORD = creds.split(":", 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not USERNAME or not PASSWORD:
|
||||
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
|
||||
print("或确保已执行 docker login", REGISTRY)
|
||||
sys.exit(1)
|
||||
|
||||
dry_run = args.dry_run or not args.execute
|
||||
mode = "预览模式" if dry_run else "执行模式"
|
||||
print("ACR 镜像清理工具 -", mode)
|
||||
print("Registry:", REGISTRY)
|
||||
print("Namespace:", NAMESPACE)
|
||||
print("保留commit tag数:", args.keep)
|
||||
print("PR预览保留天数:", args.pr_days)
|
||||
print()
|
||||
|
||||
repos_to_clean = REPOS
|
||||
if args.repo:
|
||||
repos_to_clean = [args.repo]
|
||||
|
||||
total_deleted = 0
|
||||
total_tags = 0
|
||||
for repo in repos_to_clean:
|
||||
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
|
||||
total_tags += count
|
||||
total_deleted += deleted
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("清理完成")
|
||||
print(" 总tag数:", total_tags)
|
||||
if dry_run:
|
||||
print(" 预览将删除(去重后):", total_deleted, "个manifest")
|
||||
else:
|
||||
print(" 已删除:", total_deleted, "个manifest")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -339,9 +339,10 @@ class TestIngestAssetValidation:
|
||||
True,
|
||||
)
|
||||
with patch("worker_app.tasks.ingest.SessionLocal", return_value=db):
|
||||
from worker_app.tasks.ingest import ingest_asset
|
||||
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail", return_value=None):
|
||||
from worker_app.tasks.ingest import ingest_asset
|
||||
|
||||
result = ingest_asset("job-valid-001")
|
||||
result = ingest_asset("job-valid-001")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert "asset_id" in result
|
||||
|
||||
Reference in New Issue
Block a user