Files
skill-template/tests/test_video_service.py
chendelian 3bc15d1241
All checks were successful
技能自动化发布 / release (push) Successful in 5s
chore: auto release commit (2026-07-11 15:57:40)
2026-07-11 15:57:43 +08:00

307 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""RpaVideoSession 模板示范与 result_summary / CLI 输出测试。"""
from __future__ import annotations
import io
import json
import os
import re
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import AsyncMock, MagicMock, patch
from _support import IsolatedDataRoot, get_skill_root
from jiangchang_skill_core import config
def _active_env_value(text: str, key: str) -> str | None:
"""Return the active (non-comment) KEY=value line from .env-style text."""
prefix = f"{key}="
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
active = stripped.split("#", 1)[0].strip()
if active.startswith(prefix):
return active
return None
def _combined_service_source(skill_root: str | None = None) -> str:
"""Merge scripts/service/*.py source (excludes __pycache__)."""
root = skill_root or get_skill_root()
service_dir = os.path.join(root, "scripts", "service")
parts: list[str] = []
for dirpath, dirnames, filenames in os.walk(service_dir):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for name in sorted(filenames):
if not name.endswith(".py"):
continue
path = os.path.join(dirpath, name)
with open(path, encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
def _missing_video_integration_tokens(service_text: str) -> list[str]:
missing: list[str] = []
if "RpaVideoSession" not in service_text:
missing.append("RpaVideoSession")
if "video.add_step" not in service_text and ".add_step(" not in service_text:
missing.append("video.add_step or .add_step(")
if "merge_video_into_result_summary" not in service_text:
missing.append("merge_video_into_result_summary")
return missing
class TestEnvExampleVideoDefaults(unittest.TestCase):
def test_env_example_contains_required_keys(self) -> None:
path = os.path.join(get_skill_root(), ".env.example")
with open(path, encoding="utf-8") as f:
text = f.read()
for key in (
"OPENCLAW_" + "TEST_TARGET=mock",
"OPENCLAW_RECORD_VIDEO=0",
"OPENCLAW_ARTIFACTS_ON_FAILURE=1",
"OPENCLAW_BROWSER_HEADLESS=0",
"OPENCLAW_PLAYWRIGHT_STEALTH=1",
):
self.assertIn(key, text, msg=f"missing {key!r} in .env.example")
def test_env_example_default_off_but_template_keeps_video_integration(self) -> None:
path = os.path.join(get_skill_root(), ".env.example")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertEqual(
_active_env_value(text, "OPENCLAW_RECORD_VIDEO"),
"OPENCLAW_RECORD_VIDEO=0",
)
service_text = _combined_service_source()
missing = _missing_video_integration_tokens(service_text)
self.assertEqual(
missing,
[],
msg=(
"default OPENCLAW_RECORD_VIDEO=0 does not allow removing video integration; "
f"scripts/service/*.py missing: {', '.join(missing)}"
),
)
def test_env_example_allows_record_video_one_in_comments_only(self) -> None:
sample = "\n".join(
[
"# example: OPENCLAW_RECORD_VIDEO=1 for production",
"OPENCLAW_RECORD_VIDEO=0",
]
)
self.assertEqual(
_active_env_value(sample, "OPENCLAW_RECORD_VIDEO"),
"OPENCLAW_RECORD_VIDEO=0",
)
class TestPrintVideoSummary(unittest.TestCase):
def test_cli_prints_video_path_when_enabled(self) -> None:
from service.task_run_support import _print_video_summary
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
_print_video_summary(
{
"enabled": True,
"path": r"D:\data\videos\your-skill-slug_demo.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": [],
}
)
out = buf.getvalue()
self.assertIn("录屏路径", out)
self.assertIn("your-skill-slug_demo.mp4", out)
self.assertIn("录屏日志", out)
def test_disabled_video_prints_nothing(self) -> None:
from service.task_run_support import _print_video_summary
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
_print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"})
self.assertEqual(buf.getvalue(), "")
class TestMergeVideoIntoResultSummary(unittest.TestCase):
def test_result_summary_includes_video_fields(self) -> None:
from service.task_run_support import merge_video_into_result_summary
video_info = {
"enabled": True,
"path": r"D:\data\videos\demo.mp4",
"capture_path": r"D:\data\capture.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": ["test_warning"],
"voiceover_path": r"D:\data\voiceover.wav",
"music_path": r"D:\data\music.mp3",
"audio_warnings": ["tts_skipped"],
}
payload = merge_video_into_result_summary({"demo": True}, video_info)
self.assertIn("video", payload)
self.assertEqual(payload["video_path"], video_info["path"])
self.assertEqual(payload["raw_video"], video_info["capture_path"])
self.assertEqual(payload["video_log"], video_info["record_log_path"])
self.assertEqual(payload["video_warnings"], ["test_warning"])
self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"])
self.assertEqual(payload["music_path"], video_info["music_path"])
self.assertEqual(payload["audio_warnings"], ["tts_skipped"])
class TestTemplateRunVideoSession(unittest.TestCase):
def _fake_video_session(self, mock_video: MagicMock):
class _FakeVideoSession:
def __init__(self, **kwargs):
self.kwargs = kwargs
async def __aenter__(self):
return mock_video
async def __aexit__(self, *args):
return None
return _FakeVideoSession
def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None:
with IsolatedDataRoot(user_id="_video_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
from service import task_service
mock_video = MagicMock()
mock_video.add_step = MagicMock()
mock_video.summary.return_value = {
"enabled": False,
"path": None,
"capture_path": None,
"record_log_path": None,
"warnings": [],
"voiceover_path": None,
"music_path": None,
"audio_warnings": [],
}
created: list[dict] = []
class _CapturingFakeSession:
def __init__(self, **kwargs):
created.append(kwargs)
async def __aenter__(self):
return mock_video
async def __aexit__(self, *args):
return None
with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="demo-target", input_id="42")
self.assertEqual(rc, 1)
self.assertTrue(created, "RpaVideoSession should be constructed")
kwargs = created[0]
self.assertEqual(kwargs.get("title"), "开始执行示例任务")
self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成")
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", "")))
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", "")))
mock_video.add_step.assert_any_call("准备执行示例任务")
mock_video.add_step.assert_any_call("示例任务执行完成")
mock_video.summary.assert_called()
out = buf.getvalue()
self.assertIn("模板", out)
from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
summary = json.loads(rows[0][7])
self.assertIn("video", summary)
self.assertIn("video_path", summary)
class TestCmdRunTaskLogCoverage(unittest.TestCase):
def test_cmd_run_entitlement_failure_writes_task_log(self) -> None:
with IsolatedDataRoot(user_id="_entitlement_fail"):
from service import task_service
with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t1", input_id="99")
self.assertEqual(rc, 1)
from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
_rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0]
self.assertEqual(status, "failed")
self.assertEqual(tid, "t1")
self.assertEqual(iid, "99")
self.assertIn("mock denied", err or "")
summary = json.loads(rsum or "{}")
self.assertEqual(summary.get("stage"), "auth")
def test_cmd_run_exception_writes_task_log(self) -> None:
with IsolatedDataRoot(user_id="_exception_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
from service import task_service
async def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
with patch.object(task_service, "check_entitlement", return_value=(True, "")):
with patch.object(task_service, "_run_template_demo", side_effect=_boom):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t2", input_id="88")
self.assertEqual(rc, 1)
from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
_rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0]
self.assertEqual(status, "failed")
self.assertEqual(tid, "t2")
self.assertEqual(iid, "88")
self.assertIn("任务执行异常", err or "")
summary = json.loads(rsum or "{}")
self.assertEqual(summary.get("stage"), "run")
self.assertEqual(summary.get("error"), "unexpected_exception")
def test_get_task_logger_fallback_when_not_initialized(self) -> None:
from service import task_service
mock_logger = MagicMock()
responses = [RuntimeError("logging not initialized"), mock_logger]
def _fake_get_skill_logger():
item = responses.pop(0)
if isinstance(item, Exception):
raise item
return item
with patch.object(task_service, "get_skill_logger", side_effect=_fake_get_skill_logger):
with patch.object(task_service, "setup_skill_logging") as mock_setup:
logger = task_service._get_task_logger()
mock_setup.assert_called_once_with(task_service.SKILL_SLUG, task_service.LOG_LOGGER_NAME)
self.assertIs(logger, mock_logger)
if __name__ == "__main__":
unittest.main()