diff --git a/README.md b/README.md index 280f214..108c940 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ - 对齐当前规范 skill 的目录结构:`assets/`、`references/`、`scripts/`、`tests/`、`evals/` - 对齐当前规范脚手架分层:`scripts/cli`、`scripts/db`、`scripts/service`、`scripts/util`;公共能力从共享 runtime 的 `jiangchang-platform-kit` 引用 -- 提供最小可运行入口:`python scripts/main.py health` / `version`,以及通用任务命令骨架 `run` / `logs` / `log-get` +- 提供最小可运行入口:`python scripts/main.py health` / `config-path` / `version`,以及通用任务命令骨架 `run` / `logs` / `log-get` +- 内置 **配置 bootstrap**:`.env.example` 首次落盘到 `{DATA_ROOT}/{USER_ID}/{slug}/.env`,升级后合并缺失 key(见 `scripts/util/config_bootstrap.py`) - 内置 **隔离测试体系**:`IsolatedDataRoot`、双键环境、`OPENCLAW_TEST_TARGET` 档位与 adapter profile 策略(详见 [`references/TESTING.md`](references/TESTING.md)) - 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/`、`optional/`、`skill_main.py` 结构 diff --git a/SKILL.md b/SKILL.md index bdfb1d7..07f0035 100644 --- a/SKILL.md +++ b/SKILL.md @@ -36,9 +36,12 @@ allowed-tools: ```bash python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py config-path python {baseDir}/scripts/main.py version ``` +配置:仓库 `.env.example` 为模板;用户 `.env` 在 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级:进程环境变量 > 用户 `.env` > `.env.example`。 + ## 运行依赖 - Python 运行环境由匠厂宿主注入**共享 runtime**:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 diff --git a/references/CLI.md b/references/CLI.md index 3a0fb68..6e13bf3 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -6,9 +6,24 @@ ```bash python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py config-path python {baseDir}/scripts/main.py version ``` +## config-path(配置路径) + +输出 JSON,便于排查用户 `.env` 落盘位置: + +```json +{ + "skill": "your-skill-slug", + "env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env", + "example_path": "{skill_root}/.env.example" +} +``` + +启动任意 CLI 命令前会自动执行 `bootstrap_skill_config()`(首次 copy `.env.example`,升级后合并缺失 key)。 + ## health 标准输出(runtime diagnostics) `health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,输出统一 runtime 诊断。典型字段: @@ -20,6 +35,7 @@ python {baseDir}/scripts/main.py version - `ffmpeg_path` / `ffmpeg_available` — ffmpeg 探测结果 - `background_music_mp3_count` — 可用背景音乐数量 - `runtime_issue[warning|error]` — 非致命/致命问题列表 +- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径(不输出敏感值) `health` **只读诊断**,不下载、不修复 media-assets,不执行业务 DB / 邮箱 / 订单等检查。 diff --git a/references/CONFIG.md b/references/CONFIG.md index b864cf1..8ff8f4b 100644 --- a/references/CONFIG.md +++ b/references/CONFIG.md @@ -5,10 +5,11 @@ ## 核心原则 1. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。 -2. **首次运行自动 copy**:`setup` / `doctor` / 首次 `run` 时,把 `.env.example` 复制到 - `{CLAW_DATA_ROOT}/{user}/{slug}/.env`(**已存在则不覆盖**,保护用户改过的值)。 -3. **三级优先级读取**:`进程环境变量` > `数据目录/.env` > `.env.example 默认值`。 -4. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。 +2. **首次运行自动 copy**:`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到 + `{CLAW_DATA_ROOT}/{user}/{slug}/.env`(**已存在则不整体覆盖**,保护用户改过的值)。 +3. **技能升级合并缺失项**:`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带 `comment_skill` 注释块)。 +4. **三级优先级读取**:`进程环境变量` > `数据目录/.env` > `.env.example 默认值`。 +5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。 ## 标准配置项 @@ -53,19 +54,32 @@ HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时( *.env.local ``` -## 配置读取层(模板提供) +## 配置 bootstrap(模板提供) -模板会提供一个统一 `config.py`,约定行为: +模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`: ```python -config.get("TARGET_BASE_URL") # 三级优先级解析 -config.get_bool("OPENCLAW_BROWSER_HEADLESS") -config.get_float("STEP_DELAY_MIN", 1.0) -config.ensure_env_file() # 首次把 .env.example copy 到数据目录 +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用 ``` -- 启动时调一次 `ensure_env_file()` 完成落盘。 -- 所有业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`,便于统一管理与测试覆盖。 +内部委托共享 runtime 的 `jiangchang_skill_core.config`: + +```python +config.ensure_env_file(SKILL_SLUG, example_path) # 首次落盘 +config.merge_missing_env_keys(example_path, env_path) # 升级后追加缺失 key +``` + +业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`: + +```python +config.get("TARGET_BASE_URL") +config.get_bool("OPENCLAW_BROWSER_HEADLESS") +config.get_float("STEP_DELAY_MIN", 1.0) +``` + +`config-path` 命令输出 JSON:`skill`、`env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径)。 ### 引用方式 diff --git a/references/RUNTIME.md b/references/RUNTIME.md index 196d2de..3cff2ac 100644 --- a/references/RUNTIME.md +++ b/references/RUNTIME.md @@ -32,7 +32,15 @@ Windows: - `media_assets_root`、`ffmpeg_path`、`background_music_*` - `record_video_enabled`、`runtime_issue[warning|error]` -`health` 是**只读诊断**,**不会**触发 media-assets 下载或修复。 +`health` 是**只读诊断**,**不会**触发 media-assets 下载或修复。并补充 `env_path` / `env_exists` / `example_path` 配置路径字段。 + +## 配置 bootstrap + +- 仓库内 `.env.example` 是配置模板(单一事实来源)。 +- 用户实际 `.env`:`{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/.env`。 +- `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。 +- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。 +- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.0.11`,**不得** vendored `scripts/jiangchang_skill_core/`。 ## media-assets / ffmpeg / 背景音乐 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 4747e6f..de146b5 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -7,12 +7,14 @@ import sys from typing import List, Optional from service.task_service import ( + cmd_config_path, cmd_health, cmd_log_get, cmd_logs, cmd_run, cmd_version, ) +from util.config_bootstrap import bootstrap_skill_config from util.argparse_zh import ZhArgumentParser from util.constants import LOG_LOGGER_NAME, SKILL_SLUG from util.logging_config import get_skill_logger, setup_skill_logging @@ -88,6 +90,9 @@ def build_parser() -> ZhArgumentParser: sp = sub.add_parser("health", help="健康检查") sp.set_defaults(handler=lambda _a: cmd_health()) + sp = sub.add_parser("config-path", help="输出用户 .env 与模板路径") + sp.set_defaults(handler=lambda _a: cmd_config_path()) + sp = sub.add_parser("version", help="版本信息(JSON)") sp.set_defaults(handler=lambda _a: cmd_version()) return p @@ -95,12 +100,15 @@ def build_parser() -> ZhArgumentParser: def main(argv: Optional[List[str]] = None) -> int: argv = argv if argv is not None else sys.argv[1:] + bootstrap_skill_config() setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) get_skill_logger().info("cli_start argv=%s", sys.argv) if not argv: _print_full_usage() return 1 - if len(argv) == 2 and argv[0] not in {"run", "logs", "log-get", "health", "version", "-h", "--help"}: + if len(argv) == 2 and argv[0] not in { + "run", "logs", "log-get", "health", "config-path", "version", "-h", "--help" + }: return cmd_run(target=argv[0], input_id=argv[1]) parser = build_parser() args = parser.parse_args(argv) diff --git a/scripts/main.py b/scripts/main.py index 4b0eed6..4084c53 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -28,6 +28,10 @@ from jiangchang_skill_core.runtime_env import apply_cli_local_defaults apply_cli_local_defaults() +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() + from cli.app import main if __name__ == "__main__": diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index baab75d..83bdd17 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -7,9 +7,10 @@ from __future__ import annotations import json +import os from typing import Optional -from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines +from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines from db import task_logs_repository as tlr from service.entitlement_service import check_entitlement @@ -90,15 +91,41 @@ def cmd_log_get(log_id: str) -> int: return 0 +def cmd_config_path() -> int: + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + print( + json.dumps( + { + "skill": SKILL_SLUG, + "env_path": env_path, + "example_path": os.path.abspath(example_path), + }, + ensure_ascii=False, + ) + ) + return 0 + + def cmd_health() -> int: runtime = collect_runtime_diagnostics( skill_slug=SKILL_SLUG, platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, skill_root=get_skill_root(), ) - status = "failed" if runtime.has_fatal_issues else "ok" - print(f"{SKILL_SLUG} health: {status}") - for line in format_runtime_health_lines(runtime): + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + env_exists = bool(env_path and os.path.isfile(env_path)) + + health_status = "failed" if runtime.has_fatal_issues else "ok" + lines = [ + f"{SKILL_SLUG} health: {health_status}", + *format_runtime_health_lines(runtime), + f"env_path: {env_path}", + f"env_exists: {env_exists}", + f"example_path: {os.path.abspath(example_path)}", + ] + for line in lines: print(line) return 1 if runtime.has_fatal_issues else 0 diff --git a/scripts/util/config_bootstrap.py b/scripts/util/config_bootstrap.py new file mode 100644 index 0000000..475aeeb --- /dev/null +++ b/scripts/util/config_bootstrap.py @@ -0,0 +1,19 @@ +"""技能配置初始化:.env.example 落盘与用户 .env 缺失项合并。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core import config + +from util.constants import SKILL_SLUG, SKILL_VERSION +from util.runtime_paths import get_skill_root + + +def bootstrap_skill_config() -> str: + """确保用户数据目录 .env 存在,并追加 .env.example 中的新配置项。""" + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.ensure_env_file(SKILL_SLUG, example_path) + comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None + config.merge_missing_env_keys(example_path, env_path, comment_skill=comment) + return env_path diff --git a/tests/README.md b/tests/README.md index 6bc2efb..5239188 100644 --- a/tests/README.md +++ b/tests/README.md @@ -77,6 +77,8 @@ python tests/run_tests.py test_cli_smoke | **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` | | **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` | | **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` | +| **配置 bootstrap / config-path** | ``test_config_bootstrap.py`` | +| **health runtime diagnostics** | ``test_health_runtime.py`` | 复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。 diff --git a/tests/test_config_bootstrap.py b/tests/test_config_bootstrap.py new file mode 100644 index 0000000..6adc509 --- /dev/null +++ b/tests/test_config_bootstrap.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""配置落盘、合并与读取优先级。""" +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +from jiangchang_skill_core import config +from cli.app import main +from util.config_bootstrap import bootstrap_skill_config +from util.constants import SKILL_SLUG + + +class TestConfigBootstrap(unittest.TestCase): + def test_first_run_creates_user_env(self) -> None: + with IsolatedDataRoot(user_id="_cfg_test"): + config.reset_cache() + path = bootstrap_skill_config() + self.assertTrue(os.path.isfile(path)) + self.assertTrue(path.replace("\\", "/").endswith(f"/{SKILL_SLUG}/.env")) + + def test_existing_env_not_overwritten(self) -> None: + with IsolatedDataRoot(user_id="_cfg_keep"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write("OPENCLAW_TEST_TARGET=simulator_rpa\n") + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".example") as tmp: + tmp.write("OPENCLAW_TEST_TARGET=mock\nHUMAN_WAIT_TIMEOUT=60\n") + tmp_path = tmp.name + try: + config.merge_missing_env_keys(tmp_path, path, comment_skill="test") + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("OPENCLAW_TEST_TARGET=simulator_rpa", content) + self.assertNotIn("OPENCLAW_TEST_TARGET=mock", content) + finally: + os.unlink(tmp_path) + config.reset_cache() + + def test_merge_appends_missing_keys(self) -> None: + with IsolatedDataRoot(user_id="_cfg_merge"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write("OPENCLAW_TEST_TARGET=simulator_rpa\n") + example = os.path.join(get_skill_root(), ".env.example") + added = config.merge_missing_env_keys(example, path, comment_skill="test") + self.assertIn("HUMAN_WAIT_TIMEOUT", added) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("HUMAN_WAIT_TIMEOUT=180", content) + + def test_config_priority_process_user_example(self) -> None: + with IsolatedDataRoot(user_id="_cfg_prio"): + config.reset_cache() + example = os.path.join(get_skill_root(), ".env.example") + path = config.ensure_env_file(SKILL_SLUG, example) + with open(path, "w", encoding="utf-8") as f: + f.write("PRIORITY_TEST_KEY=from_user\n") + config.reset_cache() + + os.environ["PRIORITY_TEST_KEY"] = "from_process" + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_process") + + del os.environ["PRIORITY_TEST_KEY"] + config.reset_cache() + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_user") + + with open(path, "w", encoding="utf-8") as f: + f.write("# empty user\n") + config.reset_cache() + self.assertEqual( + config.get("OPENCLAW_TEST_TARGET", "simulator_rpa"), + config.get("OPENCLAW_TEST_TARGET") or "simulator_rpa", + ) + + def test_config_path_outputs_json(self) -> None: + with IsolatedDataRoot(user_id="_cfg_path"): + config.reset_cache() + bootstrap_skill_config() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["config-path"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertTrue(payload["env_path"]) + self.assertTrue(payload["example_path"].endswith(".env.example")) + + def test_health_does_not_print_sensitive_plaintext(self) -> None: + with IsolatedDataRoot(user_id="_cfg_health"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "a", encoding="utf-8") as f: + f.write("FAKE_SECRET_TOKEN=supersecret12345\n") + config.reset_cache() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertNotIn("supersecret12345", out) + self.assertIn("env_path:", out) + self.assertIn("env_exists: True", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_health_runtime.py b/tests/test_health_runtime.py new file mode 100644 index 0000000..e427895 --- /dev/null +++ b/tests/test_health_runtime.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""health / runtime 诊断:验证委托 platform-kit 公共 API。""" +from __future__ import annotations + +import io +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +import jiangchang_skill_core +from cli.app import main +from jiangchang_skill_core import ( + collect_runtime_diagnostics, + format_runtime_health_lines, + is_jiangchang_skill_core_from_skill_tree, + runtime_diagnostics_dict, +) +from util.config_bootstrap import bootstrap_skill_config +from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG +from util.runtime_paths import get_skill_root as skill_root_path + + +def _collect_diag(): + return collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=skill_root_path(), + ) + + +class TestHealthRuntimeDiagnostics(unittest.TestCase): + def test_collect_runtime_diagnostics_fields(self) -> None: + with IsolatedDataRoot(user_id="_health_rt"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + payload = runtime_diagnostics_dict(diag) + for key in ( + "skill_slug", + "python_executable", + "platform_kit_version", + "jiangchang_skill_core_file", + "resolved_data_root", + "media_assets_root", + "ffmpeg_available", + "background_music_mp3_count", + "runtime_issues", + ): + self.assertIn(key, payload) + self.assertEqual(diag.skill_slug, SKILL_SLUG) + self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION) + + def test_format_runtime_health_lines_contains_core_fields(self) -> None: + diag = _collect_diag() + text = "\n".join(format_runtime_health_lines(diag)) + self.assertIn("skill_slug:", text) + self.assertIn("python_executable:", text) + self.assertIn("platform_kit_version:", text) + self.assertIn("jiangchang_skill_core_file:", text) + self.assertIn("media_assets_root:", text) + self.assertIn("background_music_mp3_count:", text) + + def test_health_cli_prints_runtime_diagnostics(self) -> None: + with IsolatedDataRoot(user_id="_health_cli"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + out = buf.getvalue() + self.assertEqual(rc, 0, msg=out) + self.assertIn("python_executable:", out) + self.assertIn("platform_kit_version:", out) + self.assertIn("jiangchang_skill_core_file:", out) + self.assertIn("media_assets_root:", out) + self.assertIn("background_music_mp3_count:", out) + self.assertIn("env_path:", out) + self.assertIn("example_path:", out) + + def test_health_fails_when_platform_kit_missing(self) -> None: + with IsolatedDataRoot(user_id="_health_no_pkg"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + with patch( + "jiangchang_skill_core.runtime_diagnostics._platform_kit_version", + return_value=None, + ): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 1) + self.assertIn("platform_kit_not_installed", buf.getvalue()) + + def test_background_music_issue_is_warning_not_fatal(self) -> None: + from jiangchang_skill_core.media_assets import MediaAssetsStatus + + with IsolatedDataRoot(user_id="_health_music_warn"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "1" + media_root = Path(os.environ["CLAW_DATA_ROOT"]) / "shared" / "media-assets" + ffmpeg_path = media_root / "tools" / "ffmpeg.exe" + ffmpeg_path.parent.mkdir(parents=True, exist_ok=True) + ffmpeg_path.write_bytes(b"x" * 256) + status = MediaAssetsStatus( + root=media_root, + exists=True, + ready=False, + source="local", + warnings=["music_dir_missing"], + ffmpeg_path=ffmpeg_path, + music_dir=None, + ) + music_probe = { + "music_root": None, + "mp3_count": 0, + "usable_count": 0, + "sample_path": None, + "issue": "music_dir_missing", + } + with patch( + "jiangchang_skill_core.runtime_diagnostics.probe_media_assets", + return_value=status, + ), patch( + "jiangchang_skill_core.runtime_diagnostics.probe_background_music", + return_value=music_probe, + ), platform_kit_version_patch(): + diag = _collect_diag() + music_issues = [i for i in diag.issues if i.code == "background_music_unavailable"] + self.assertTrue(music_issues) + self.assertEqual(music_issues[0].severity, "warning") + self.assertFalse(diag.has_fatal_issues) + + def test_skill_tree_core_load_detected_as_issue(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join( + skill_root, "scripts", "fake_pkg", "jiangchang_skill_core", "__init__.py" + ) + self.assertTrue( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, core_file=fake_core + ) + ) + self.assertFalse( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, + core_file=jiangchang_skill_core.__file__, + ) + ) + + def test_skill_tree_core_load_issue_in_diagnostics(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py") + with patch.object(jiangchang_skill_core, "__file__", fake_core): + diag = _collect_diag() + codes = diag.issue_codes() + self.assertIn("jiangchang_skill_core_loaded_from_skill_tree", codes) + messages = [issue.message for issue in diag.issues] + self.assertTrue(any("skill tree" in msg.lower() for msg in messages)) + + +class TestHealthRuntimeWithFakeMedia(unittest.TestCase): + def test_probe_counts_mp3_without_download(self) -> None: + with IsolatedDataRoot(user_id="_health_media"): + media_root = Path(os.environ["CLAW_DATA_ROOT"]) / "shared" / "media-assets" + music_dir = media_root / "music" + music_dir.mkdir(parents=True) + (music_dir / "demo.mp3").write_bytes(b"\x00" * 1024) + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + self.assertGreaterEqual(diag.background_music_mp3_count, 1) + self.assertGreaterEqual(diag.background_music_usable_count, 1) + self.assertIsNone(diag.background_music_issue) + + +if __name__ == "__main__": + unittest.main()