继续完善代码
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
- 对齐当前规范 skill 的目录结构:`assets/`、`references/`、`scripts/`、`tests/`、`evals/`
|
- 对齐当前规范 skill 的目录结构:`assets/`、`references/`、`scripts/`、`tests/`、`evals/`
|
||||||
- 对齐当前规范脚手架分层:`scripts/cli`、`scripts/db`、`scripts/service`、`scripts/util`;公共能力从共享 runtime 的 `jiangchang-platform-kit` 引用
|
- 对齐当前规范脚手架分层:`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))
|
- 内置 **隔离测试体系**:`IsolatedDataRoot`、双键环境、`OPENCLAW_TEST_TARGET` 档位与 adapter profile 策略(详见 [`references/TESTING.md`](references/TESTING.md))
|
||||||
- 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/`、`optional/`、`skill_main.py` 结构
|
- 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/`、`optional/`、`skill_main.py` 结构
|
||||||
|
|
||||||
|
|||||||
3
SKILL.md
3
SKILL.md
@@ -36,9 +36,12 @@ allowed-tools:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {baseDir}/scripts/main.py health
|
python {baseDir}/scripts/main.py health
|
||||||
|
python {baseDir}/scripts/main.py config-path
|
||||||
python {baseDir}/scripts/main.py version
|
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`。
|
- Python 运行环境由匠厂宿主注入**共享 runtime**:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
|
||||||
|
|||||||
@@ -6,9 +6,24 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {baseDir}/scripts/main.py health
|
python {baseDir}/scripts/main.py health
|
||||||
|
python {baseDir}/scripts/main.py config-path
|
||||||
python {baseDir}/scripts/main.py version
|
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 标准输出(runtime diagnostics)
|
||||||
|
|
||||||
`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,输出统一 runtime 诊断。典型字段:
|
`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,输出统一 runtime 诊断。典型字段:
|
||||||
@@ -20,6 +35,7 @@ python {baseDir}/scripts/main.py version
|
|||||||
- `ffmpeg_path` / `ffmpeg_available` — ffmpeg 探测结果
|
- `ffmpeg_path` / `ffmpeg_available` — ffmpeg 探测结果
|
||||||
- `background_music_mp3_count` — 可用背景音乐数量
|
- `background_music_mp3_count` — 可用背景音乐数量
|
||||||
- `runtime_issue[warning|error]` — 非致命/致命问题列表
|
- `runtime_issue[warning|error]` — 非致命/致命问题列表
|
||||||
|
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径(不输出敏感值)
|
||||||
|
|
||||||
`health` **只读诊断**,不下载、不修复 media-assets,不执行业务 DB / 邮箱 / 订单等检查。
|
`health` **只读诊断**,不下载、不修复 media-assets,不执行业务 DB / 邮箱 / 订单等检查。
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
1. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
|
1. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
|
||||||
2. **首次运行自动 copy**:`setup` / `doctor` / 首次 `run` 时,把 `.env.example` 复制到
|
2. **首次运行自动 copy**:`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到
|
||||||
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`(**已存在则不覆盖**,保护用户改过的值)。
|
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`(**已存在则不整体覆盖**,保护用户改过的值)。
|
||||||
3. **三级优先级读取**:`进程环境变量` > `数据目录/.env` > `.env.example 默认值`。
|
3. **技能升级合并缺失项**:`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带 `comment_skill` 注释块)。
|
||||||
4. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
|
4. **三级优先级读取**:`进程环境变量` > `数据目录/.env` > `.env.example 默认值`。
|
||||||
|
5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
|
||||||
|
|
||||||
## 标准配置项
|
## 标准配置项
|
||||||
|
|
||||||
@@ -53,19 +54,32 @@ HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(
|
|||||||
*.env.local
|
*.env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置读取层(模板提供)
|
## 配置 bootstrap(模板提供)
|
||||||
|
|
||||||
模板会提供一个统一 `config.py`,约定行为:
|
模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
config.get("TARGET_BASE_URL") # 三级优先级解析
|
from util.config_bootstrap import bootstrap_skill_config
|
||||||
config.get_bool("OPENCLAW_BROWSER_HEADLESS")
|
|
||||||
config.get_float("STEP_DELAY_MIN", 1.0)
|
bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用
|
||||||
config.ensure_env_file() # 首次把 .env.example copy 到数据目录
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 启动时调一次 `ensure_env_file()` 完成落盘。
|
内部委托共享 runtime 的 `jiangchang_skill_core.config`:
|
||||||
- 所有业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`,便于统一管理与测试覆盖。
|
|
||||||
|
```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` 绝对路径)。
|
||||||
|
|
||||||
### 引用方式
|
### 引用方式
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ Windows:
|
|||||||
- `media_assets_root`、`ffmpeg_path`、`background_music_*`
|
- `media_assets_root`、`ffmpeg_path`、`background_music_*`
|
||||||
- `record_video_enabled`、`runtime_issue[warning|error]`
|
- `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 / 背景音乐
|
## media-assets / ffmpeg / 背景音乐
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import sys
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from service.task_service import (
|
from service.task_service import (
|
||||||
|
cmd_config_path,
|
||||||
cmd_health,
|
cmd_health,
|
||||||
cmd_log_get,
|
cmd_log_get,
|
||||||
cmd_logs,
|
cmd_logs,
|
||||||
cmd_run,
|
cmd_run,
|
||||||
cmd_version,
|
cmd_version,
|
||||||
)
|
)
|
||||||
|
from util.config_bootstrap import bootstrap_skill_config
|
||||||
from util.argparse_zh import ZhArgumentParser
|
from util.argparse_zh import ZhArgumentParser
|
||||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||||
from util.logging_config import get_skill_logger, setup_skill_logging
|
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 = sub.add_parser("health", help="健康检查")
|
||||||
sp.set_defaults(handler=lambda _a: cmd_health())
|
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 = sub.add_parser("version", help="版本信息(JSON)")
|
||||||
sp.set_defaults(handler=lambda _a: cmd_version())
|
sp.set_defaults(handler=lambda _a: cmd_version())
|
||||||
return p
|
return p
|
||||||
@@ -95,12 +100,15 @@ def build_parser() -> ZhArgumentParser:
|
|||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
argv = argv if argv is not None else sys.argv[1:]
|
argv = argv if argv is not None else sys.argv[1:]
|
||||||
|
bootstrap_skill_config()
|
||||||
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
||||||
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
||||||
if not argv:
|
if not argv:
|
||||||
_print_full_usage()
|
_print_full_usage()
|
||||||
return 1
|
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])
|
return cmd_run(target=argv[0], input_id=argv[1])
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
|
|||||||
|
|
||||||
apply_cli_local_defaults()
|
apply_cli_local_defaults()
|
||||||
|
|
||||||
|
from util.config_bootstrap import bootstrap_skill_config
|
||||||
|
|
||||||
|
bootstrap_skill_config()
|
||||||
|
|
||||||
from cli.app import main
|
from cli.app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import Optional
|
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 db import task_logs_repository as tlr
|
||||||
from service.entitlement_service import check_entitlement
|
from service.entitlement_service import check_entitlement
|
||||||
@@ -90,15 +91,41 @@ def cmd_log_get(log_id: str) -> int:
|
|||||||
return 0
|
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:
|
def cmd_health() -> int:
|
||||||
runtime = collect_runtime_diagnostics(
|
runtime = collect_runtime_diagnostics(
|
||||||
skill_slug=SKILL_SLUG,
|
skill_slug=SKILL_SLUG,
|
||||||
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
|
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
|
||||||
skill_root=get_skill_root(),
|
skill_root=get_skill_root(),
|
||||||
)
|
)
|
||||||
status = "failed" if runtime.has_fatal_issues else "ok"
|
example_path = os.path.join(get_skill_root(), ".env.example")
|
||||||
print(f"{SKILL_SLUG} health: {status}")
|
env_path = config.get_env_file_path() or ""
|
||||||
for line in format_runtime_health_lines(runtime):
|
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)
|
print(line)
|
||||||
return 1 if runtime.has_fatal_issues else 0
|
return 1 if runtime.has_fatal_issues else 0
|
||||||
|
|
||||||
|
|||||||
19
scripts/util/config_bootstrap.py
Normal file
19
scripts/util/config_bootstrap.py
Normal file
@@ -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
|
||||||
@@ -77,6 +77,8 @@ python tests/run_tests.py test_cli_smoke
|
|||||||
| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` |
|
| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` |
|
||||||
| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` |
|
| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` |
|
||||||
| **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` |
|
| **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` |
|
||||||
|
| **配置 bootstrap / config-path** | ``test_config_bootstrap.py`` |
|
||||||
|
| **health runtime diagnostics** | ``test_health_runtime.py`` |
|
||||||
|
|
||||||
复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。
|
复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。
|
||||||
|
|
||||||
|
|||||||
116
tests/test_config_bootstrap.py
Normal file
116
tests/test_config_bootstrap.py
Normal file
@@ -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()
|
||||||
178
tests/test_health_runtime.py
Normal file
178
tests/test_health_runtime.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user