Compare commits

...

20 Commits

Author SHA1 Message Date
87ef00835b chore: auto release commit (2026-06-14 11:17:52)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-14 11:17:53 +08:00
c7d7832749 chore: auto release commit (2026-06-14 11:12:03)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-06-14 11:12:04 +08:00
955816c8a7 docs: remove internal developer reminder from user-facing README
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-13 15:38:47 +08:00
7827cc75b2 chore: auto release commit (2026-06-13 14:59:54)
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-06-13 14:59:54 +08:00
a24f6809da chore: auto release commit (2026-06-08 14:19:45)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-06-08 14:19:46 +08:00
5b555e4313 去掉依赖 2026-06-07 14:50:44 +08:00
4c5bc31b0e 继续优化代码 2026-06-07 12:02:17 +08:00
45cf1741e1 继续完善代码 2026-06-07 11:51:23 +08:00
1bbcc65cf3 重新调整模板 2026-06-07 11:28:45 +08:00
ae5466854f fix: sync unified logging probe before file handler attach
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-02 15:18:59 +08:00
b7163f12a5 chore: sync ffmpeg video session and logging fallback
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:29:02 +08:00
f52536bdcc chore: sync ffmpeg RpaVideoSession, remove Playwright recording
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:07:02 +08:00
38343d22ca chore: sync RpaVideoSession standard and default OPENCLAW_RECORD_VIDEO=1
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 13:29:50 +08:00
685ad20bd2 完善模板,加入RPA标准 2026-05-31 10:33:05 +08:00
e4418d68e4 docs: standardize skill requirements declaration 2026-05-29 17:38:15 +08:00
c20d6498da feat(template): 升级 desktop E2E 模板到本系列最佳实践
基于 disburse-payroll-icbc 实战经验沉淀,让所有未来新 skill 复制模板后直接
享受本系列全部改进:

- record_screencast.py.sample:
  · 字幕脚本改用 "step: N/X" 模式 + ASCII 关键词锚(避免编码问题)
  · 展示三元组 (keyword, text, duration) 用法(依赖 platform-kit subtitle.py)
  · 加 TEST_FILE_NAME / MUSIC_SUBDIR 切换常量
  · 文档说明绕开 SDK ask() / send_file 的最佳实践

- test_desktop_smoke.py.sample:
  · docstring 加"何时用此模板 / 何时改用 _with_attachment"指引
  · 标注已知 SDK 限制(ask() user_idx<0 / send_file NotImplementedError)

- test_desktop_smoke_with_attachment.py.sample(新增):
  · 完整附件场景模板,import jiangchang_desktop_sdk.e2e_helpers 6 个 API
  · 7 步 step: N/7 模式,跟 record_screencast.py.sample 字幕脚本对齐
  · 占位符 SKILL_SLUG / PROMPT / EXPECTED_KEYWORDS_IN_REPLY

- README.md: 追加 3 节
  · 模板选择指南(纯问答 vs 附件场景)
  · 已知 SDK 坑(send_file / ask / 字幕关键词 / bring_to_front)
  · 录屏新能力(activate_window / monitor_index / 字幕三元组)

- conftest.py: 同步到支持新版 platform-kit src/ 布局的 SDK 路径推断

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:14:59 +08:00
a656534461 feat(tests/desktop): add record_screencast.py.sample template
Generic screencast recording entry point for skill demo videos.
Copy to record_screencast.py and update SKILL_SLUG + SUBTITLE_SCRIPT.
Requires jiangchang-platform-kit/tools/screencast + mss + ffmpeg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:39:04 +08:00
579db91ab2 fix: revert accidental name change in release_skill.yaml 2026-05-10 12:11:13 +08:00
fd247f6ec3 测试仓库 2026-05-10 12:04:09 +08:00
d347e36fea chore: minor doc polish after refactor review
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 11:56:05 +08:00
55 changed files with 2990 additions and 538 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# ── 运行模式 / adapter 档位(见 references/ADAPTER.md──
OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa
# ── 目标系统 ──
TARGET_BASE_URL=https://sandbox.jc2009.com # 仿真/生产地址,可被进程环境变量覆盖
# ── 默认账号(仅非敏感标识;密码走 account-manager──
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 存证 ──
OPENCLAW_RECORD_VIDEO=1
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒)
STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒)
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)

View File

@@ -5,7 +5,7 @@ on:
jobs: jobs:
release: release:
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@v1.0.14
secrets: secrets:
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }} PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
with: with:

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.env .env
*.env.local

120
README.md
View File

@@ -1,54 +1,94 @@
# 匠厂 技能开发模板(通用业务) ---
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
---
这是一个**规范化的新技能模板仓库**,用于复制出新的 skill 项目;它本身**不是业务 skill**。 # 【技能名称】
## 模板目标 > 用一两句话说明这个技能帮助用户完成什么工作。
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
- 对齐当前规范 skill 的目录结构:`assets/``references/``scripts/``tests/``evals/` ---
- 对齐当前规范脚手架分层:`scripts/cli``scripts/db``scripts/service``scripts/util``scripts/jiangchang_skill_core`
- 提供最小可运行入口:`python scripts/main.py health` / `version`,以及通用任务命令骨架 `run` / `logs` / `log-get`
- 内置 **隔离测试体系**`IsolatedDataRoot`、双键环境、`OPENCLAW_TEST_TARGET` 档位与 adapter profile 策略(详见 [`references/TESTING.md`](references/TESTING.md)
- 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/``optional/``skill_main.py` 结构
## 新技能使用步骤 ## 这个技能能帮你做什么
1. 复制本目录为新的 skill 仓库。 用 3 到 6 条业务能力说明技能价值,例如:
2. 全局替换 `your-skill-slug``your-platform-key``您的技能显示名称` 等占位内容。
3. 修改 `SKILL.md``references/``scripts/util/constants.py`
4.`scripts/service/task_service.py` 中实现真实业务编排与外部系统对接。
5. 运行 `python tests/run_tests.py -v`,再执行 `python scripts/main.py health``version` 做最小验证。
如果你的技能在平台里默认是非公开的(`access_scope = 0`),建议在 `SKILL.md``metadata.openclaw.developer_ids` 中填写开发者用户 ID 列表。这样在正式发布后,平台会为这些开发者补可见权限,避免「技能已上架但开发者自己在市场中看不到」。 - 【自动整理/查询/生成/同步/核对/提交】某类业务数据
- 帮你减少重复操作,降低人工录入或核对成本
- 在任务完成后给出清晰的处理结果、失败原因或后续建议
开发教程入口: ---
- <a href="references/REQUIREMENTS.md" target="_blank" rel="noopener noreferrer">需求文档模板</a>:给技术人员编写和查看研发需求的标准模板 ## 适合什么场景
- <a href="references/DEVELOPMENT.md" target="_blank" rel="noopener noreferrer">开发教程</a>:给技术人员的完整开发步骤说明
- <a href="references/TESTING.md" target="_blank" rel="noopener noreferrer">测试开发指南</a>:默认套件、隔离数据根、档位开关与 FakeAdapter 怎么用
## 目录说明 列出典型使用场景。请使用用户熟悉的业务语言,例如:
| 路径 | 用途 | - 每天需要重复处理同一类订单、单据、报表或客户资料
|------|------| - 需要从多个系统中查询信息并汇总结果
| `SKILL.md` | 技能清单与触发说明模板 | - 需要按固定规则检查数据是否完整、是否异常
| `assets/` | 示例输出与轻量 schema | - 需要把处理结果保存为文件、记录或任务报告
| `references/` | 面向用户与编排的文档模板 |
| `scripts/` | 规范分层后的代码骨架 |
| `tests/` | 单元测试与分层测试范式(默认根目录 unittest |
| `evals/` | 人工/半自动评估材料 |
| `.github/workflows/release_skill.yaml` | 标准发布工作流 |
| `release.ps1` | 对齐现有 skill 的发布脚本入口 |
## 最小命令 ---
```bash ## 使用前需要准备什么
python scripts/main.py health
python scripts/main.py version
```
## 注意 说明用户在使用前要准备的东西,例如:
- 不要再往模板里引入旧式 `docs/``optional/` 目录。 - 相关账号已登录,且具备查询、导出或提交权限
- 新技能若不需要某些目录,也建议先保留结构,再按实际业务填充内容。 - 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- `metadata.openclaw.developer_ids` 是发布元数据,不是用户展示文案;用于非公开技能的开发者默认可见授权。 - 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用
- **发起 `release.ps1` 之前**,务必确认 `python tests/run_tests.py -v` 已全部通过。
---
## 你可以这样告诉 Agent
用自然语言示例展示用户可以怎么发起任务,例如:
> “帮我查询今天的【业务对象】,整理成表格。”
>
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
>
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
>
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
> 注意:示例应是自然语言,不要写命令行命令。
---
## 执行后你会得到什么
说明用户会看到什么结果,例如:
- 任务是否成功完成
- 处理了多少条数据
- 哪些内容成功、哪些失败
- 失败原因和建议处理方式
- 生成的文件、表格、报告或记录
---
## 注意事项
用用户能理解的方式说明边界和风险,例如:
- 请确认输入范围准确,避免处理错数据
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
- 不建议在未核对数据的情况下直接执行批量操作
---
## 常见问题
### 为什么任务没有查到数据?
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
### 为什么任务执行到一半需要我操作?
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。

View File

@@ -1,11 +1,12 @@
--- ---
name: 技能开发模板(通用业务版) name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,覆盖任务执行、日志记录、外部系统对接、隔离测试等典型能力。复制后请按 references/DEVELOPMENT.md 完成定制。" description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.14 version: 1.0.14
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
slug: your-skill-slug slug: your-skill-slug
platform_kit_min_version: "1.0.14"
emoji: "📦" emoji: "📦"
category: "通用" category: "通用"
developer_ids: developer_ids:
@@ -17,32 +18,54 @@ allowed-tools:
# 技能开发模板skill-template # 技能开发模板skill-template
这是一个**用于复制的新技能模板**,不是业务技能本身。新建技能时,应复制本仓库结构,再把占位内容替换成你的真实业务实现 这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑
## 模板使用方式 ## 文档分工
1. 复制目录为你的新 skill 仓库。 | 文档 | 读者 | 用途 |
2. 全局替换 `your-skill-slug``技能开发模板(通用业务版)` 等占位词。 |------|------|------|
3.`references/CLI.md``scripts/` 分层与 `README.md` 的说明补业务逻辑。 | 根目录 `README.md` | 普通用户 | 技能市场详情页说明(`metadata.readme_md` 主来源) |
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
| `references/` | Agent 编排/调用 | 渐进式加载CLI 契约、字段 schema 等 |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、技术规范 |
## 目录约定 ## 目录约定
- 根目录结构参考现有规范技能:`assets/``references/``scripts/``tests/``evals/` 复制后建议保留:
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util / jiangchang_skill_core` 分层。 - `assets/``development/``evals/``references/``scripts/``tests/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util` 分层
## 最小命令 ## 最小命令
```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`
- 复制后请同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION` ## 运行依赖
- 如技能无需持久化,可保留 `db/` 目录但不主动调用。
- `metadata.openclaw.developer_ids` 用于声明技能发布后的默认开发者可见用户 ID 列表 - Python 运行环境由匠厂宿主注入**共享 runtime**`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 当技能在平台中 `access_scope = 0`(不公开)时,任务执行流程会把 `developer_ids` 中的用户自动补写到 `skill_user_access`,使这些开发者仍可在技能市场中查看该技能 - 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.14``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本
- config、logging、runtime_env、rpa、media-assets、video_session、runtime_diagnostics 等均从共享 venv 的 `jiangchang_skill_core` import而非技能目录副本。
- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.14`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装。
- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets
## 定制入口
1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。
2.`development/DEVELOPMENT.md` 完成开发与文档定制。
3. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md`
4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
-`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id` - `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`
- 面向用户与编排的文档写在 `references/`,不要再新增旧式 `docs/` / `optional/` 结构。

View File

@@ -3,4 +3,6 @@
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。
面向编排与用户文档见 `references/README.md``references/CLI.md``RUNTIME.md``SCHEMA.md` - 用户市场说明见根目录 [`README.md`](../README.md)
- Agent 调用/编排参考见 [`references/`](../references/)
- 开发规范见 [`development/`](../development/)

108
development/ADAPTER.md Normal file
View File

@@ -0,0 +1,108 @@
# 适配器标准:真实/仿真 × API/RPA 四档模式
> 凡是"连接三方系统"ERP、CRM、SaaS、银行等的 skill都应采用本文的 **adapter 四档模式**。这样同一套业务逻辑可以在不同档位间切换:开发用 mock、半集成用 simulator、上线用真实互不影响。
## 为什么要分档
对接一个外部系统,工程上无非四种方式的组合:
| | API有接口 | RPA操作界面 |
|---|---|---|
| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 |
| **仿真/离线** | `mock` 纯内存/fixture | `simulator_rpa` 仿真站点/桌面仿真 |
### 四档定义
| 档位 | 含义 | 默认策略 |
|------|------|----------|
| **`mock`** | 纯内存或 fixture**默认单测/CI** | 模板 `.env.example` 默认 `OPENCLAW_TEST_TARGET=mock` |
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 开发联调可选 |
| **`real_api`** | 真实 API | **必须** `ALLOW_REAL_API=1` |
| **`real_rpa`** | 真实浏览器/真实系统 | **必须** `ALLOW_REAL_RPA=1` |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑端到端流程但不碰生产。
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。
> 推荐优先级:**real_api > simulator_rpa > real_rpa**mock 永远保留做 CI。
## 权限开关
未显式授权时**不得**落到真实 API/RPA
| 变量 | 作用 |
|------|------|
| `ALLOW_REAL_API=1` | 允许 `real_api` 访问真实 HTTP |
| `ALLOW_REAL_RPA=1` | 允许 `real_rpa` 驱动真实浏览器/RPA |
| `ALLOW_WRITE_ACTIONS=1` | 在 `real_*` 下允许**写**操作(提交表单、下单等) |
进程环境变量优先于用户 `.env`(见 `CONFIG.md` 三层优先级)。
## 目录骨架
```
scripts/service/<domain>_adapter/
__init__.py # dispatch按档位返回对应 adapter 实例
base.py # 数据契约dataclass+ AdapterBase 接口
mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPA
real_rpa.py # 真实系统 RPA占位谨慎实现
```
模板示例:`scripts/service/example_adapter/`(复制改名即用)。
## 档位 dispatch
`OPENCLAW_TEST_TARGET` 统一决定用哪个 adapter
| `OPENCLAW_TEST_TARGET` | adapter | 用途 |
|---|---|---|
| `unit` / `mock` | `MockAdapter` | 单测 / CI离线 |
| `simulator_rpa` | `SimRpaAdapter` | 开发/演示,操作仿真平台 |
| `real_api` | `RealApiAdapter` | 生产,走官方接口 |
| `real_rpa` | `RealRpaAdapter` | 生产,操作真实界面 |
```python
# __init__.py
def get_adapter():
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "mock").lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter()
```
`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**未授权时不 silently 开启真实网络/RPA**。
## contract tests
每个 adapter **必须覆盖同一套契约测试**(复制 `tests/samples/test_service_contract.py.sample`
- 至少覆盖:**timeout**、**unauthorized**、**invalid response**、**empty result**
- **mock 不允许真实网络**
- 使用 `FakeAdapter` 或等价 stub 模拟异常路径
## 兄弟依赖
依赖 account-manager 或其他兄弟技能时:
1.`SKILL.md``metadata.openclaw.dependencies.required` 声明。
2. 调用兄弟技能走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 service 层散落 `subprocess.run`
3. **lease 必须 release**account-manager 租约);进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。
```python
from service.sibling_bridge import call_sibling_json
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
```
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `CONFIG.md``.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

99
development/CONFIG.md Normal file
View File

@@ -0,0 +1,99 @@
# 配置标准:`.env` 规范与首次落盘机制
> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`skill 首次运行自动落盘到用户数据目录,之后从那里读。**
## 核心原则
1. **三层优先级**`进程环境变量` > `{数据目录}/.env` > 仓库 `.env.example` 默认值。
2. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
3. **首次运行自动 copybootstrap**`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`**已存在则不整体覆盖**,保护用户改过的值)。
4. **技能升级合并缺失项**`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带注释块)。
5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
## 标准配置项(`.env` 分组)
每个 skill 的 `.env.example` 至少包含这些通用项(按需增减):
```ini
# ── 运行模式 / adapter 档位(见 ADAPTER.md──
OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa
# ── 目标系统(示例占位,复制后改为业务地址)──
TARGET_BASE_URL=https://sandbox.jc2009.com
# ── 默认账号(仅非敏感标识;密码走 account-manager──
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)
OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=开启反检测指纹(默认)
# ── 录屏与失败存证 ──
OPENCLAW_RECORD_VIDEO=1 # 1=录屏+字幕+旁白+背景音+MP4RPA 默认开,见 RPA.md
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0
STEP_DELAY_MAX=5.0
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)
```
**业务专属配置必须带技能前缀**(如 `DEMO_XXX``MY_SKILL_XXX`**不要污染全局命名空间**(禁止 `SCRAPE_1688_*``RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
## 🚫 红线:敏感信息不进 `.env`
`.env` 只放**非敏感运行参数**模式、地址、开关、超时、账号标识。密码、密钥、动态口令、token——
-**account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用;
- 真实值由宿主/用户通过进程环境变量注入,**不落任何文件、不进 git**。
`.gitignore` 必须包含:
```gitignore
.env
*.env.local
```
## 配置 bootstrap模板提供
模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`
```python
from util.config_bootstrap import bootstrap_skill_config
bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用
```
内部委托共享 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)
```
## health / config-path
- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok``ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`
- **`config-path`**:输出 JSON包含 `skill``env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置。
## doctor / setup 命令(可选)
每个 skill 可提供自检命令:
- `python scripts/main.py doctor`检查浏览器、Profile/账号、`.env` 落盘、设备等。
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`
## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义
- `ADAPTER.md``OPENCLAW_TEST_TARGET` 四档模式
- `RUNTIME.md``CLAW_*` 环境变量与数据目录约定

View File

@@ -92,17 +92,18 @@
your-skill/ your-skill/
├─ .github/ ├─ .github/
├─ assets/ ├─ assets/
├─ development/
├─ evals/ ├─ evals/
├─ references/ ├─ references/
├─ scripts/ ├─ scripts/
│ ├─ cli/ │ ├─ cli/
│ ├─ db/ │ ├─ db/
│ ├─ jiangchang_skill_core/
│ ├─ service/ │ ├─ service/
│ └─ util/ │ └─ util/
├─ tests/ ├─ tests/
├─ .gitignore ├─ .gitignore
├─ README.md ├─ README.md
├─ requirements.txt
├─ release.ps1 ├─ release.ps1
└─ SKILL.md └─ SKILL.md
``` ```
@@ -110,7 +111,10 @@ your-skill/
各目录职责如下: 各目录职责如下:
- `assets/`放示例输出、schema、静态说明资源 - `assets/`放示例输出、schema、静态说明资源
- `references/`:放研发和编排需要长期维护的文档 - `README.md`:面向用户的市场详情页说明
- `SKILL.md`:面向 LLM / 平台的技能入口
- `references/`Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
- `scripts/`:放真正的代码 - `scripts/`:放真正的代码
- `tests/`:放自动化测试 - `tests/`:放自动化测试
- `evals/`:放人工验收材料、评估清单、示例场景 - `evals/`:放人工验收材料、评估清单、示例场景
@@ -124,7 +128,6 @@ scripts/
├─ main.py ├─ main.py
├─ cli/ ├─ cli/
├─ db/ ├─ db/
├─ jiangchang_skill_core/
├─ service/ ├─ service/
└─ util/ └─ util/
``` ```
@@ -150,9 +153,59 @@ scripts/
- `util/` - `util/`
作用:常量、日志、路径、时间工具、通用帮助函数 作用:常量、日志、路径、时间工具、通用帮助函数
- `jiangchang_skill_core/` 公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics从共享 runtime 的 `jiangchang-platform-kit>=1.0.14` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
作用:运行时环境与统一日志副本
一般按现有规范技能复制,不要自己乱改结构 ## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读三份标准**`RPA.md`(三端范式与反反爬)、`CONFIG.md``.env` 落盘与读取)、`ADAPTER.md`(四档 adapter
2. **从模板复制骨架**
- `scripts/service/example_adapter/` → 改名为 `scripts/service/<domain>_adapter/`
- 参考 `scripts/service/example_browser_rpa.py` 写浏览器会话逻辑
3. **只用共享库,不在 skill 里重写反反爬**
```python
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **mock 档必须离线可跑**`OPENCLAW_TEST_TARGET=mock`sim_rpa / real_* 按需单独测。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`**不要在新 skill 里重复造包**(尚待实战验证)。
---
### 3.1 `requirements.txt` 依赖规范
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**`jiangchang-platform-kit``playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`
- `SKILL.md``metadata.openclaw.platform_kit_min_version`(当前 `1.0.14`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
- `pkg>=1.2.0,<2`
- 对**原生扩展 / 高风险依赖**(如 `chromadb``onnxruntime`)建议 pin 或窄范围,避免无上限:
- 不推荐:`chromadb>=0.5.0`
- 推荐:`chromadb>=0.5.23,<0.6`
- **不要**把系统组件VC++ Runtime、浏览器安装包等写进 `requirements.txt`;这类前置条件写在 `health` / preflight 文档与错误提示中。
- 若技能无额外 Python 依赖,可保留空文件或仅含注释说明;**不要**为 platform-kit 或 playwright 保留占位行。
- 独立本地开发环境若缺少公共包,可手动安装;**生产/宿主运行**由共享 runtime 负责。
### 3.3 公共能力不要复制
以下模块由 platform-kit 提供,技能内**不要**重复实现或 vendored 拷贝:
| 能力 | 导入方式 |
|------|----------|
| config | `from jiangchang_skill_core import config` |
| logging | `from jiangchang_skill_core.unified_logging import ...``util.logging_config` 薄封装 |
| runtime_env | `from jiangchang_skill_core.runtime_env import ...``util.runtime_paths` 薄封装 |
| rpa | `from jiangchang_skill_core.rpa import ...` |
| media_assets | `from jiangchang_skill_core.media_assets import ...` |
| video_session | `from jiangchang_skill_core.rpa.video_session import RpaVideoSession` |
| runtime_diagnostics | `from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines` |
`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`
## 4. 开发一个新 skill 的标准步骤 ## 4. 开发一个新 skill 的标准步骤
@@ -174,9 +227,10 @@ scripts/
复制后优先改下面这些地方: 复制后优先改下面这些地方:
1. `SKILL.md` 1. `SKILL.md`
2. `scripts/util/constants.py` 2. 根目录 `README.md`(用户市场说明)
3. `references/` 下的文案 3. `scripts/util/constants.py`
4. `scripts/service/` 下的业务占位实现(优先改 `task_service.py` 4. `references/` `development/` 下的文案
5. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`
最先要统一的是: 最先要统一的是:
@@ -200,47 +254,44 @@ scripts/
如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换: 如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换:
- `run` 命令中的中文提示与别名策略(见 `references/CLI.md` - `run` 命令中的中文提示与别名策略(见 `../references/CLI.md`
- `references/CLI.md` 的命令示例 - `../references/CLI.md` 的命令示例
- `references/README.md` 的用户话术 - `../README.md` 的用户话术
- `references/SCHEMA.md` 的字段映射补充说明 - `../references/SCHEMA.md` 的字段映射补充说明
如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。 如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。
## 6. `SKILL.md` 应该怎么写 ## 6. `SKILL.md` 与根 `README.md` 应该怎么写
`SKILL.md` 是技能清单,不是设计文档。 ### `SKILL.md`LLM / 平台入口)
`SKILL.md` 是给 LLM 与 OpenClaw 平台看的技能入口,**不是**用户市场详情页。
应该重点写: 应该重点写:
- 技能名称 - YAML frontmatter`name``description``version``metadata.openclaw`(含 `slug``platform_kit_min_version``developer_ids` 等)
- 技能描述 - 何时触发本技能、CLI 入口摘要、运行契约要点
- `slug` - 指向 `references/`Agent 调用)与 `development/`(开发规范)的分工说明
作用:技能的唯一英文标识,通常用于仓库名、发布包名、运行时目录名、平台主键匹配等
示例:`your-skill-slug``disburse-payroll-icbc``tax-invoice-verify` 不要在 `SKILL.md` 里写大量用户市场文案或实现细节。
- `category`
作用:技能在平台中的分类,用于市场展示与归类,不是代码内部键 ### 根 `README.md`(用户市场说明)
示例:`通用``内容``办公协作``物流``抖音``小红书`
- `developer_ids`(如需给非公开技能自动补开发者可见权限) `README.md` 是技能市场详情页主来源(`metadata.readme_md`),面向普通用户。
作用:声明发布后默认拥有可见权限的开发者用户 ID 列表
- `dependencies` 应包含 YAML frontmatter 的 `description`(市场列表简介),正文用用户能理解的语言说明:
作用:声明该技能依赖的兄弟技能或运行前置能力,便于平台或编排层识别依赖关系
示例(按业务填写;发布类可能会依赖账号 / 内容技能,其它领域可能完全不同): - 能做什么、适合什么场景、使用前准备什么
```yaml - 如何自然语言告诉 Agent
dependencies: - 执行结果、注意事项、常见问题
required:
- some-sibling-skill 不要在根 `README.md` 里写开发教程、目录规范、release、requirements 等技术细节。
```
- 何时使用本技能
- 对用户的引导话术
- CLI 使用原则
不要在 `SKILL.md` 里写大量实现细节。
实现细节放在: 实现细节放在:
- `references/` - `development/`
- 代码注释 - `references/`CLI / SCHEMA
- `service/` 实现 - 代码注释与 `service/` 实现
### 关于 `metadata.openclaw.developer_ids` ### 关于 `metadata.openclaw.developer_ids`
@@ -269,33 +320,31 @@ metadata:
- 第一个 ID 会同步到 `skills.developer_id` - 第一个 ID 会同步到 `skills.developer_id`
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权 - 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
## 7. `references/` 应该放什么 ## 7. 文档目录分工
`references/`当前规范 skill 的文档中心,建议至少有这些 当前规范 skill 的文档按读者拆分
- `README.md` **根 `README.md`**
面向内部说明和技能作用介绍
- `CLI.md` - 用户市场详情页说明(含 frontmatter `description`
写清楚命令、参数、默认值、兄弟技能调用方式
- `RUNTIME.md` **`SKILL.md`**
写清楚运行时目录、环境变量、入口约定
- `SCHEMA.md` - LLM / OpenClaw 平台技能入口
写清楚数据库路径、核心表结构、日志表结构
- `DEVELOPMENT.md` **`references/`**Agent 运行/编排/调用时渐进加载)
写给技术人员的开发教程,也就是本文档
- `TESTING.md` - `README.md` — Agent 参考索引
写给技术人员的测试分层、隔离数据根与档位开关指南(详见 [`references/TESTING.md`](TESTING.md) - `CLI.md` — 命令、参数、默认值、兄弟技能调用方式
- `SCHEMA.md` — 数据库路径、核心表结构、日志表结构
- 可按需增加 `ERRORS.md``INTEGRATION.md` 等编排向补充
如果后面某个 skill 需要更细的说明,可以再加: **`development/`**(开发者 / AI 编程代理)
- `ERRORS.md` - `REQUIREMENTS.md` — 需求与验收
- `INTEGRATION.md` - `DEVELOPMENT.md` — 开发教程(本文档)
- `PLATFORMS.md` - `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)
- `ADAPTER.md``RPA.md``CONFIG.md``RUNTIME.md` — 技术规范
## 8. `assets/` 应该放什么 ## 8. `assets/` 应该放什么
@@ -310,7 +359,7 @@ metadata:
比如日志记录、机读 JSON 的 schema 比如日志记录、机读 JSON 的 schema
不要把正式研发文档放到 `assets/` 不要把正式研发文档放到 `assets/`
文档应该进 `references/` 用户说明进根 `README.md`Agent 编排资料进 `references/`;开发规范进 `development/`
## 9. `cli` 层怎么写 ## 9. `cli` 层怎么写
@@ -461,13 +510,13 @@ python scripts/main.py <your-command>
新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍: 新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍:
1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认 6 个默认测试套件全部通过。 1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认全部默认测试套件通过。
- 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。 - 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。
2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。 2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。
3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。 3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。
4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。 4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。
详见 `references/TESTING.md` 详见 `TESTING.md`
## 15. 发布到正式环境验证 ## 15. 发布到正式环境验证
@@ -524,7 +573,7 @@ python scripts/main.py <your-command>
如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如: 如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如:
- 发布脚本是否正常推送 - 发布脚本是否正常推送
- `SKILL.md``scripts/``references/` 是否齐全 - `SKILL.md``README.md``scripts/``references/``development/` 是否齐全
- 工作流文件是否存在 - 工作流文件是否存在
- 发布包结构是否符合模板规范 - 发布包结构是否符合模板规范
@@ -587,7 +636,7 @@ python scripts/main.py <your-command>
- [ ] 目录结构符合当前模板 - [ ] 目录结构符合当前模板
- [ ] `SKILL.md` 中 slug、名称、描述都已替换 - [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `scripts/util/constants.py` 已修改 - [ ] `scripts/util/constants.py` 已修改
- [ ] `references/CLI.md` 示例命令已改成真实命令 - [ ] `../references/CLI.md` 示例命令已改成真实命令
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现 - [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
- [ ] 没有残留旧平台名 - [ ] 没有残留旧平台名
- [ ] `health` / `version` 可运行 - [ ] `health` / `version` 可运行
@@ -624,7 +673,7 @@ python scripts/main.py <your-command>
- `task_service.py` - `task_service.py`
- `sibling_bridge.py` - `sibling_bridge.py`
- `references/CLI.md` - `../references/CLI.md`
### 错误 3把业务逻辑写进 CLI ### 错误 3把业务逻辑写进 CLI
@@ -649,16 +698,17 @@ python scripts/main.py <your-command>
现在的新模板原则是: 现在的新模板原则是:
- 不做旧结构兼容 - 不做旧结构兼容
- 统一走 `references/` + `scripts/main.py` - 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 统一走 `scripts/main.py` 作为 CLI 入口
## 18. 推荐开发顺序总结 ## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序: 如果让一个新人照着做,我建议他按这个顺序:
1. 复制模板并改目录名 1. 复制模板并改目录名
2. 改 `SKILL.md` 2. 改 `SKILL.md` 与根 `README.md`
3. 改 `scripts/util/constants.py` 3. 改 `scripts/util/constants.py`
4. 改 `references/` 4. 改 `references/``development/`
5. 改 `scripts/cli/app.py` 5. 改 `scripts/cli/app.py`
6. 改 `scripts/service/` 6. 改 `scripts/service/`
7. 跑 `python tests/run_tests.py -v` 7. 跑 `python tests/run_tests.py -v`
@@ -672,7 +722,7 @@ python scripts/main.py <your-command>
- 目录结构统一 - 目录结构统一
- 入口统一为 `scripts/main.py` - 入口统一为 `scripts/main.py`
- 文档统一放 `references/` - 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 业务核心逻辑统一放 `scripts/service/` - 业务核心逻辑统一放 `scripts/service/`
- 不再使用旧模板历史结构 - 不再使用旧模板历史结构

14
development/README.md Normal file
View File

@@ -0,0 +1,14 @@
# 开发资料入口
本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读:
1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
2. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
3. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径与诊断约定
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。

View File

@@ -164,6 +164,20 @@
- `【平台名】`:作用是 `【用途】` - `【平台名】`:作用是 `【用途】`
- `【浏览器 / API / 第三方服务】`:作用是 `【用途】` - `【浏览器 / API / 第三方服务】`:作用是 `【用途】`
### Python 包依赖requirements.txt
- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`
- `jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `SKILL.md``platform_kit_min_version` 是运行契约/兼容性声明,供宿主校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 requirements 安装到 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
- **不要**把系统运行时组件(如 Microsoft Visual C++ Redistributable写进 requirements.txt。
### 系统运行时前置条件preflight / health
- 若依赖 Windows 原生 DLL、本机浏览器 channel 等**系统级**能力,在 `health` / preflight 中检测并提示用户自行安装。
- 此类依赖**不**通过 pip / requirements.txt 由宿主自动安装。
示例: 示例:
### 兄弟技能依赖 ### 兄弟技能依赖
@@ -217,6 +231,8 @@
- skill 可以正常安装 - skill 可以正常安装
- 安装后可在“新建任务”中调用 - 安装后可在“新建任务”中调用
**测试与日志相关验收(所有新 skill 必须满足)**
- `python tests/run_tests.py -v` 必跑测试全部通过 - `python tests/run_tests.py -v` 必跑测试全部通过
- 任务日志task_logs写入和查询符合预期 - 任务日志task_logs写入和查询符合预期
- 真实联调(如有)已在 tests/integration/ 中验证,且默认套件不包含真实外联 - 真实联调(如有)已在 tests/integration/ 中验证,且默认套件不包含真实外联
@@ -257,8 +273,8 @@
建议每个新 skill 开发时,按下面顺序使用文档: 建议每个新 skill 开发时,按下面顺序使用文档:
1. 先写 `references/REQUIREMENTS.md` 1. 先写 `REQUIREMENTS.md`
2. 再按 `references/DEVELOPMENT.md` 进入开发 2. 再按 `DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `CLI.md``SCHEMA.md``RUNTIME.md``TESTING.md` 3. 开发过程中补充 `CLI.md``SCHEMA.md``RUNTIME.md``TESTING.md`
4. 发布前回到 `REQUIREMENTS.md` 对照验收标准逐项检查 4. 发布前回到 `REQUIREMENTS.md` 对照验收标准逐项检查

239
development/RPA.md Normal file
View File

@@ -0,0 +1,239 @@
# RPA 操作标准(浏览器 / 桌面 / 手机)
> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill都应先读这份文档按这里的选型和范式落地不要每个项目重新踩坑。
我们开发的各类 skill本质上都是在替人操作三类界面**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:保持登录态、有头运行、拟人操作、失败存证、人工兜底。
---
## 0. 三端通用范式(先看这个)
无论操作哪类界面,都遵循同一套约定:
| 约定 | 说明 |
|------|------|
| **保持登录态** | 复用持久化 Profile / session避免每次重新登录触发风控账号由 account-manager 下发,不硬编码 |
| **有头运行** | 默认有头headless 易被识别 / 难人工介入);`OPENCLAW_BROWSER_HEADLESS=1` 仅给 CI |
| **拟人操作** | 真实事件isTrusted=true逐字输入、随机延迟、贝塞尔鼠标轨迹严禁 JS 直接设值/JS 点击/JS 跳转 |
| **步骤间随机等待** | 每两步操作之间 `random_delay(min,max)`,区间由 `.env` 配置(默认 1~5s |
| **人工兜底HITL** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
| **选择器纪律** | 语义选择器优先id/name/text/aria**F12 确认后再写,严禁凭记忆猜 DOM** |
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
> 三端各自实现一个会话抽象 `RpaSession`launch / login / act / screenshot / close上层 skill 不感知是浏览器还是手机。
---
## 1. 浏览器(标准已成熟)
**选型Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts``receive-order` 已落地)。
### 1.0 生产路径(优先)
| 项 | 标准 |
|----|------|
| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context``channel="chrome"` 或 Edge不用内置 Chromium**技能内不要 `playwright install`** |
| 登录态 | 持久 Profile 目录账号、profile、lease **统一走 account-manager**(或对应兄弟技能),不硬编码密码 |
| CDP | **仅作诊断 / 桌面宿主类场景****不要**作为强风控站点的默认生产路径 |
| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 |
| 模式 | 默认有头 `OPENCLAW_BROWSER_HEADLESS=0`;无头仅 CI |
| 反检测 | stealth 默认开 `OPENCLAW_PLAYWRIGHT_STEALTH=1`(见 1.1 |
### 1.1 Playwright 启动标准
1. **默认有头**`OPENCLAW_BROWSER_HEADLESS=0``.env.example` 默认值)。
2. **stealth 默认开**`OPENCLAW_PLAYWRIGHT_STEALTH=1`;通过 `add_init_script` 注入指纹淡化脚本。
3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。
4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。
5. **不要默认传 `--disable-blink-features=AutomationControlled`**platform-kit stealth 已覆盖,额外 flag 可能适得其反。
6. **可以** `ignore_default_args=["--enable-automation"]`platform-kit `launch_persistent_browser` 已处理)。
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、持久 profile**不要**直接拼接搜索结果 URL 或 DOM 注入。
指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.0.14**)。
**拟人操作**(必做):
- 输入:逐字符 `keyboard.type(delay=90~240ms)`**先真实点击聚焦再输入,绝不 `el.value=`**。
- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。
- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。
- 翻页:真实点击翻页控件,注意排除禁用态。
- 延迟:每步之间 `random_delay``.env``STEP_DELAY_MIN/MAX`)。
### 1.2 页面启动标准
优先级(高 → 低):
1. **launch args `start_url`**:浏览器启动即打开目标首页(推荐生产路径)。
2. **profile preferences**:作为兜底(例如 homepage / startup URLs
3. **程序化导航 `page.goto`**:仅最后兜底;强风控站点慎用裸 `goto` 深链。
**生产路径不要依赖 CDP 接管现有页面**CDP 仅用于诊断或桌面宿主已打开浏览器的场景。
### 1.3 HITL / 验证码
- 自动处理失败时**允许等待人工**滑块、短信、人脸、U盾、动态口令等。
- 等待人工验证时必须有:**字幕 step**(用户可见动作说明)、**结构化日志**、**超时**`HUMAN_WAIT_TIMEOUT`)。
- 检测到风控页URL/DOM 特征)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等待或超时,**不要强行绕过平台安全机制**。
- **不要自动操作滑块**。
> 共享库:`jiangchang_skill_core.rpa.wait_for_captcha_pass`;参考 `scripts/service/example_browser_rpa.py`。
### 1.4 安装
生产/宿主运行:`playwright` 由共享 runtime 提供,无需在技能内 `pip install`
独立本地开发环境若缺少 Python 包 `playwright` 时,可手动执行:
```bash
pip install playwright # 仅独立开发补装;用系统 Chrome无需 playwright install chromium
```
### 引用方式
共享实现位于宿主共享 runtime 安装的 `jiangchang-platform-kit``jiangchang_skill_core.rpa`)。复制后的业务技能直接 import**技能仓库不得保留 rpa 公共代码副本**
```python
from jiangchang_skill_core.rpa import (
launch_persistent_browser,
anti_detect,
wait_for_captcha_pass,
capture_failure,
errors,
)
from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT
```
- `RpaVideoSession` 来自 platform-kit **>= 1.0.14**ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。
- `health` 对上述资源做只读诊断,不下载、不修复。
完整示例见 `scripts/service/example_browser_rpa.py``scripts/service/example_adapter/sim_rpa.py`
---
## 2. 桌面软件Windows 原生程序)
**选型pywinautoUIA backend为主 + 图像识别兜底。**
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
| 技术 | 优先级 | 适用 / 说明 |
|------|--------|------|
| **pywinauto`backend="uia"`** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType**稳定、不依赖屏幕坐标**,纯 Python |
| **FlaUI**(经 pythonnet 调 .NET | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX图像识别** | ⚠️ 兜底 | 控件树完全拿不到时Flash/远程桌面/纯自绘 UI**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
### 桌面端注意事项
- **窗口聚焦/置顶**:操作前确保目标窗口前置,避免误操作其它窗口。
- **DPI/缩放**图像识别方案必须固定显示缩放比例UIA 方案不受影响(优先用 UIA 即是为此)。
- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`
- **人工兜底**U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`
```bash
pip install pywinauto # UIA 自动化
# 图像兜底pip install pyautogui opencv-python
```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 3. 手机软件USB 连接电脑)
**选型Android 用 uiautomator2或 AppiumiOS 用 Appium + WebDriverAgent需 Mac** 底层都是经 USB 的 ADB / WDA。
| 平台 | 技术 | 优先级 | 说明 |
|------|------|--------|------|
| **Android** | **uiautomator2**python 原生) | ✅ 首选 | ADB over USB直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
| Android | **Appium**uiautomator2 driver | 备选 | 需要跨平台统一接口、或团队已有 Appium 资产时用 |
| Android | **Airtest + Poco**(网易开源) | 兜底 | 图像+控件混合,自带 IDE 可录制;控件树拿不到时用 |
| **iOS** | **Appium + WebDriverAgentXCUITest** | 唯一可行 | **必须有一台 Mac 做中转**Windows host 无法直接驱动 iOS |
| 投屏/人工介入 | **scrcpy** | 辅助 | USB 投屏到电脑,配合人工过验证码/人脸 |
### 手机端注意事项
- **设备就绪检查**`adb devices` 确认已授权连接;放进 `doctor` 自检。
- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。
- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。
- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`
```bash
# Android首选
pip install uiautomator2
python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appiumpip install Appium-Python-Client另需 Appium Server
```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
---
## 4. 统一错误码RPA 场景)
skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层判断与重试:
| 错误码 | 含义 | 上层处理建议 |
|--------|------|------|
| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程 |
| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 |
| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
---
## 5. 存证与录屏规范
### 5.1 截图存证
- **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **Playwright 不负责录屏**,仅浏览器自动化。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`
### 5.2 RPA 视频 step 标准
字幕是**用户可见动作说明**不是技术日志。step 要贴近真实动作,不要只在大流程入口打点。
推荐关键动作示例(按业务裁剪):
- 启动浏览器
- 打开首页
- 检查登录状态
- 定位输入框
- 输入关键词xxx
- 点击搜索
- 等待结果
- 打开详情页
- 提取信息
- 写入结果
- 任务完成
技术诊断、重复跳过、DB 写入可以显示但**通常不需要旁白**。
`title` / `closing_title` 必须由 skill 传入**中文业务文案**(如「开始执行示例任务」「示例任务执行完成」)。
### 5.3 录屏成片标准
- RPA skill 默认 `OPENCLAW_RECORD_VIDEO=1`
- 使用 platform-kit 的 **`RpaVideoSession`****skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4
- `OPENCLAW_RECORD_VIDEO=0` 时 session 无副作用(不启 ffmpeg、不写字幕文件
- **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`)。
- **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等。
- 任务完成后 CLI / `result_summary` 应包含:`video_path``raw_video``video_log``video_warnings``music_path``voiceover_path``audio_warnings`(见 `scripts/service/task_run_support.py`)。
模板最小示范见 `scripts/service/task_service.py``_run_template_demo()`
---
## 相关文档
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md``.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

113
development/RUNTIME.md Normal file
View File

@@ -0,0 +1,113 @@
# 运行时约定
## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime`jiangchang-platform-kit>=1.0.14` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored应由共享 venv 的 site-packages 提供。
技能根目录 `requirements.txt` **只声明技能特有依赖****不要**重复声明 `jiangchang-platform-kit``playwright``SKILL.md``platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
| 场景 | 推荐做法 |
|------|----------|
| 日常运行 | 由宿主匠厂触发技能 |
| 手工排查 / 测试机 | 使用共享 runtime 的 `python.exe` 执行 `scripts/main.py` |
| **不推荐** | 在技能目录内 `uv run python …` — 可能创建临时 venv加载不到宿主共享 runtime |
占位命令(路径因环境而异,勿写死本机目录):
```text
Windows:
{JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health
通用:
<shared-python> {baseDir}/scripts/main.py health
```
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入;开发模式下也可能通过 `CLAW_DATA_ROOT` 解析(见 `jiangchang_skill_core.runtime_env`)。
## Runtime 诊断platform-kit 1.0.14+
`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段:
- `skill_slug``python_executable``platform_kit_version``platform_kit_min_version`
- `jiangchang_skill_core_file` — 若从技能目录加载会输出 `runtime_issue[warning]`
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` / `resolved_data_root`
- `media_assets_root``ffmpeg_path``background_music_*`
- `record_video_enabled``runtime_issue[warning|error]`
`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.14`**不得** vendored `scripts/jiangchang_skill_core/`
## media-assets / ffmpeg / 背景音乐
背景音乐、ffmpeg 等共享资源由 platform-kit 统一解析,默认路径:
```text
{JIANGCHANG_DATA_ROOT}/shared/media-assets
```
(与 `CLAW_DATA_ROOT` 解析规则一致,由宿主注入。)
RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均由 platform-kit 提供;技能只 import不重复实现。
## 目录结构
新技能建议采用以下根目录结构:
- `assets/`
- `development/`
- `references/`
- `scripts/`
- `tests/`
- `evals/`
**标准 `scripts/` 分层(不含 `jiangchang_skill_core/` 副本):**
- `scripts/main.py`:唯一 CLI 入口
- `scripts/cli/`:参数解析与命令分发
- `scripts/db/`SQLite 或本地持久化层
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径薄封装
## 数据路径
推荐:
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
```
数据库文件建议:
```text
{...}/{skill_slug}.db
```
## 明确禁止
- **不得** vendored `scripts/jiangchang_skill_core/`
- **不得**在技能内本地重复实现 `RuntimeDiagnostics` / `collect_runtime_diagnostics`
- **不得**写死开发机绝对路径作为运行时约定
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间同时设置:
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `CLAW_USER_ID` / `JIANGCHANG_USER_ID``_test`
退出时四个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `TESTING.md` 第 3 节。
## 编码与输出
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
- 机读输出优先单行 JSON
- 错误前缀建议统一 `ERROR:`

View File

@@ -1,6 +1,6 @@
# 测试开发指南 # 测试开发指南
面向复制 `skill-template` 后的新业务 skill**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`references/TESTING.md`(本篇)、[`references/DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。 面向复制 `skill-template` 后的新业务 skill**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`TESTING.md`(本篇)、[`DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。
默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**`*integration*``*.sample``desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。 默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**`*integration*``*.sample``desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。
@@ -22,7 +22,8 @@
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖: 必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health` / `version` / `logs` / `log-get` 冒烟; - CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`runtime diagnostics/ `version` / `logs` / `log-get` 冒烟;
- 架构守护:无 `scripts/jiangchang_skill_core/``platform-kit>=1.0.14` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- **真实 subprocess**[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移; - **真实 subprocess**[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths`**`CLAW_*` / `JIANGCHANG_*` 并发兜底; - 运行时:`runtime_paths`**`CLAW_*` / `JIANGCHANG_*` 并发兜底;
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py) - `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
@@ -101,7 +102,7 @@ def test_whatever():
**何时用它**service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter而不是直接把 CLI when-json 断言堆上天。 **何时用它**service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter而不是直接把 CLI when-json 断言堆上天。
把它看成:**你把不确定性折叠进可控表格**,而不是在生产日志里才第一次看到错位字段。 把它看成:**你把不确定性收敛到可控的测试矩阵里**,而不是在生产日志里才第一次看到错位字段。
--- ---
@@ -124,12 +125,12 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
任何 touching **真实租户数据** 的路径: 任何 touching **真实租户数据** 的路径:
1. **禁止**硬编码 token / cookie / 内部域名落入仓库; 1. **禁止**硬编码 token / cookie / 内部域名落入仓库;
2. **禁止**默认套件 silent import integration 2. **禁止**默认套件隐式导入 integration
3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。 3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。
范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。 范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。
记住:**测试代码也是一种部署面**,别把 staging 凭证烘焙进来 记住:**测试代码也是一种部署面**,别把 staging 凭证写死进仓库
--- ---
@@ -156,9 +157,9 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
| 红线 | 解释 | | 红线 | 解释 |
|------|------| |------|------|
| 不改业务凑测试 | 除非需求变更已确认,否则别让 CI 绿通过阉割业务分支达成 | | 不改业务凑测试 | 除非需求变更已确认,否则**不要为了让 CI 变绿而砍掉业务分支** |
| **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` | | **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` |
| `.sample` 尊重 | 集成范式改名前先读完 README别让 `.sample` silent 变成根测试 | | `.sample` 尊重 | 集成范式改名前先读完 README别让 `.sample` 悄悄变成根测试 |
| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref | | **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref |
| mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 | | mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 |
| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 | | 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 |
@@ -178,8 +179,54 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
--- ---
## 延伸阅读 ## 11. pytest 收集卫生标准
模板根目录提供 `pytest.ini`(或等价配置),约束:
- **只收集** `test_*.py` / `*_test.py`
- **不让** `.txt`、结果文件、日志文件被 pytest 误收集
- `norecursedirs` 排除 `integration/``desktop/``samples/``fixtures/``artifacts/``diagnostics/`
测试结果文件**不要**放在 `tests/` 根目录;应放 `tests/artifacts/``tests/diagnostics/` 并加入 `.gitignore`
默认 `python tests/run_tests.py` 仍只发现 `tests/` 根目录一层 `test_*.py`(不递归子目录),与 pytest 策略一致。
---
## 12. RPA / video 测试标准
- `RpaVideoSession` 调用**不跑真实 ffmpeg**:单测中使用 `unittest.mock` patch session 或设 `OPENCLAW_RECORD_VIDEO=0`
- 断言 `title` / `closing_title` 是**中文**业务文案。
- 断言 video artifact 会进入 `result_summary``video_path``raw_video``video_log` 等)。
- 断言 step 文案贴近用户动作不是技术日志如「准备执行示例任务」而非「enter cmd_run」
参考 `tests/test_video_service.py`
---
## 13. 宿主 E2E 标准
- 使用 `jiangchang_desktop_sdk.e2e_helpers`(见 `tests/desktop/``.sample`)。
- **不伪造**用户数据目录;通过宿主 IPC 获取真实 `skill_data_dir`
- E2E **不自动生成**真实密钥或生产凭证。
---
## 14. 测试前检查清单(模板守护)
复制新 skill 或修改模板后,确认:
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.0.14**`SKILL.md` + `constants.py`
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`
- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件
- [ ] `python tests/run_tests.py -v` 全部通过
---
- [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则 - [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则
- [`references/DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序 - [`DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序
- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法 - [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法

8
pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics
filterwarnings =
ignore::DeprecationWarning

View File

@@ -1,4 +1,4 @@
# 通用业务技能模板 CLI 模板 # 通用业务技能模板 CLI 标准
`{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。 `{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。
@@ -6,41 +6,82 @@
```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
``` ```
## 通用业务命令骨架 ## 标准行为
模板默认提供以下命令(复制后按需保留或扩展): - **任意 CLI 启动前**执行 `bootstrap_skill_config()``main.py``cli.app.main()` 均触发)。
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets不执行业务动作**;不输出敏感值。
- **`config-path`**:输出 JSON包含 `skill``env_path``example_path`
- **`version`**:输出 JSON`version``skill`)。
- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。
- **任务完成后**若有 video artifactCLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。
## config-path配置路径
```json
{
"skill": "your-skill-slug",
"env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env",
"example_path": "{skill_root}/.env.example"
}
```
## health 标准输出runtime diagnostics
`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,典型字段:
- `python_executable` — 当前 Python 解释器路径
- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok`
- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning
- `media_assets_root``ffmpeg_path``ffmpeg_available``background_music_mp3_count`
- `runtime_issue[warning|error]` — 非致命/致命问题列表
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径
## 通用业务命令骨架
```bash ```bash
python {baseDir}/scripts/main.py run python {baseDir}/scripts/main.py run
python {baseDir}/scripts/main.py run -t <target> -i <input_id> python {baseDir}/scripts/main.py run -t <target> -i <input_id>
python {baseDir}/scripts/main.py logs python {baseDir}/scripts/main.py logs
python {baseDir}/scripts/main.py logs --task-type disburse --status failed python {baseDir}/scripts/main.py logs --task-type demo --status failed
python {baseDir}/scripts/main.py log-get <log_id> python {baseDir}/scripts/main.py log-get <log_id>
``` ```
含义: | 命令 | 用途 |
- `run`:执行一次任务(具体语义由你的 skill 决定) |------|------|
- `logs`:列出最近 N 条任务日志,可按 task_type / status / target 过滤 | `run` | 执行一次任务 |
- `log-get`:按 log_id 查看单条任务日志JSON | `logs` | 列出最近 N 条任务日志 |
| `log-get` | 按 log_id 查看单条任务日志JSON |
## 若你的技能是发布类 ## 查询/导出类(可选)
可以把 `run` 命令对外别名为 `publish`,参数 `--target` 别名为 `--account-id`,参数 `--input-id` 保持不变 复制后按需增加,例如:`contacts``contact-get``export` 或业务等价命令
## 若你的技能依赖兄弟技能 ## 若你的技能依赖兄弟技能
使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 sibling_bridge.py 自身。 使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 `sibling_bridge.py` 自身;并在 `SKILL.md` 声明 `dependencies.required`
```bash ## 手工排查命令(推荐)
# 假设 base 与兄弟技能同级
python {baseDir}/../<sibling-skill-slug>/scripts/main.py ... **建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库:
```text
Windows:
{JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health
通用:
<shared-python> {baseDir}/scripts/main.py health
``` ```
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
**不建议**在生产/测试机使用技能目录内 `uv run python`,以免加载不到共享 runtime 中的 `jiangchang-platform-kit` / `playwright`
## 模板约定 ## 模板约定
- 最小模板至少保留 `health` / `version` - 最小模板至少保留 `health` / `config-path` / `version`
- 通用业务技能建议使用 `run` / `logs` / `log-get` - 通用业务技能建议使用 `run` / `logs` / `log-get`
- 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名 - 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名

View File

@@ -1,48 +1,13 @@
--- # Agent 参考索引
description: "规范化的新技能模板说明复制后替换名称、CLI 示例与 task_service 等业务实现。"
---
# 技能模板说明 本目录供 Agent **运行、编排、调用** skill 时渐进式加载,不是用户市场说明。
这个仓库是**给开发者复制的新技能模板**,不是终端用户直接调用的业务 skill。 | 文档 | 用途 |
|------|------|
| 根目录 [`README.md`](../README.md) | 用户市场详情页说明 |
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 |
| [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 |
| [`SCHEMA.md`](SCHEMA.md) | 输入输出、数据库与字段说明 |
| [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) |
## 它提供什么 按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;运行时环境细节见 `../development/RUNTIME.md`
- 标准目录结构
- 最小 CLI 入口(`run` / `logs` / `log-get` / `health` / `version`
- 通用任务日志表骨架(`task_logs`
- `service` 层占位模块(`task_service.py`、通用 `sibling_bridge.py`
- 成熟的隔离测试体系IsolatedDataRoot、FakeAdapter、6 档测试 target、profile 策略)
- 与现有规范 skill 一致的发布脚本与 GitHub workflow
## 复制后你需要改什么
- `SKILL.md` 中的名称、描述、slug、触发说明
- `SKILL.md``metadata.openclaw.developer_ids`(如需让非公开技能默认授权给开发者查看)
- `references/CLI.md` 里的命令示例
- `scripts/util/constants.py` 中的 slug / 版本 / logger 名
- `scripts/service/task_service.py` 中的 `cmd_run` 真实业务逻辑
- 如有特定 task_type`references/SCHEMA.md` 中补充字段映射说明
## `developer_ids` 是做什么的
`metadata.openclaw.developer_ids` 是平台发布元数据,不是终端用户文案。
它用于声明:当技能发布后,如果平台侧将该技能设置为 `access_scope = 0`(不公开),哪些开发者用户仍应自动获得可见权限。
约定如下:
- 字段位置:`SKILL.md` -> `metadata.openclaw.developer_ids`
- 推荐格式:正整数数组,例如 `[1032, 12428]`
- 发布效果:发布接口会把这些用户补写到 `skill_user_access`
- 第一个 ID 会作为主开发者同步到 `skills.developer_id`
如果你的技能本来就是公开技能,或暂时不需要开发者专属可见性,这个字段可以留空数组。
## 不建议再保留的旧结构
- 旧模板里的 `docs/`
- 旧模板里的 `optional/`
- 旧入口 `scripts/skill_main.py`
新模板统一使用 `scripts/main.py` 作为入口。

View File

@@ -1,51 +0,0 @@
# 运行时约定
## 目录结构
新技能建议采用以下根目录结构:
- `assets/`
- `references/`
- `scripts/`
- `tests/`
- `evals/`
## `scripts/` 分层
- `scripts/main.py`:唯一 CLI 入口
- `scripts/cli/`:参数解析与命令分发
- `scripts/db/`SQLite 或本地持久化层
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径
- `scripts/jiangchang_skill_core/`:运行时与统一日志副本
## 数据路径
推荐:
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
```
数据库文件建议:
```text
{...}/{skill_slug}.db
```
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间同时设置:
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `CLAW_USER_ID` / `JIANGCHANG_USER_ID``_test`
退出时四个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `references/TESTING.md` 第 3 节。
## 编码与输出
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
- 机读输出优先单行 JSON
- 错误前缀建议统一 `ERROR:`

View File

@@ -12,7 +12,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" $sharedScript = Join-Path $scriptDir "..\..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) $sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (-not (Test-Path $sharedScript)) { if (-not (Test-Path $sharedScript)) {

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。

View File

@@ -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
@@ -55,6 +57,7 @@ def _print_full_usage() -> None:
print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]") print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]")
print(" python main.py log-get <log_id>") print(" python main.py log-get <log_id>")
print(" python main.py health") print(" python main.py health")
print(" python main.py config-path")
print(" python main.py version") print(" python main.py version")
@@ -88,6 +91,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 +101,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)

View File

@@ -1 +0,0 @@
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.

View File

@@ -1,109 +0,0 @@
"""
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
模板复制后通常无需大改;如组织环境不同,再按项目实际调整。
"""
from __future__ import annotations
import os
import sys
CLI_LOCAL_DEV_ENABLED = True
DEFAULT_LOCAL_USER_ID = "10032"
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
def platform_default_data_root() -> str:
if sys.platform == "win32":
return WIN_DEFAULT_DATA_ROOT
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
def get_data_root() -> str:
env = (
os.getenv("CLAW_DATA_ROOT")
or os.getenv("JIANGCHANG_DATA_ROOT")
or ""
).strip()
if env:
return env
return platform_default_data_root()
def get_user_id() -> str:
return (
os.getenv("CLAW_USER_ID")
or os.getenv("JIANGCHANG_USER_ID")
or ""
).strip() or "_anon"
def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path):
return False
# 通过常见基础技能存在与否,判断这是不是 skills 根目录
# 这里列举的是平台基础设施类技能,不包含具体业务技能
for marker in (
"llm-manager",
"skill-template",
):
if os.path.isdir(os.path.join(path, marker)):
return True
return False
def get_skills_root() -> str:
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
if sys.platform == "win32" and not app:
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
if app:
nested = os.path.join(app, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(app):
return os.path.normpath(app)
if sys.platform == "win32":
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
if skill_scripts_dir:
scripts = os.path.abspath(skill_scripts_dir)
skill_root = os.path.dirname(scripts)
inferred = os.path.dirname(skill_root)
if _looks_like_skills_root(inferred):
return os.path.normpath(inferred)
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
return get_skills_root()
def apply_cli_local_defaults() -> None:
enabled = CLI_LOCAL_DEV_ENABLED
if not enabled:
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
enabled = v in ("1", "true", "yes", "on")
if not enabled:
return
if not (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
if not (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip():
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()

View File

@@ -1,137 +0,0 @@
"""
统一文件日志:{DATA_ROOT}/{USER_ID}/logs/jiangchang.log
按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。
"""
from __future__ import annotations
import logging
import os
import sys
import uuid
from logging.handlers import TimedRotatingFileHandler
from typing import Optional
from .runtime_env import get_data_root, get_user_id
_skill_slug: str = ""
_logger_name: str = ""
def get_unified_logs_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), "logs")
os.makedirs(path, exist_ok=True)
return path
def get_skill_log_file_path() -> str:
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
if override:
parent = os.path.dirname(os.path.abspath(override))
if parent:
os.makedirs(parent, exist_ok=True)
return os.path.abspath(override)
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def ensure_trace_for_process() -> str:
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if existing:
return existing
tid = uuid.uuid4().hex[:12]
os.environ["JIANGCHANG_TRACE_ID"] = tid
return tid
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
ensure_trace_for_process()
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or ensure_trace_for_process()
base = os.environ if environ is None else environ
return {**base, "JIANGCHANG_TRACE_ID": tid}
class _SkillContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
record.skill_slug = _skill_slug or "-"
return True
_FORMAT = "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def _backup_count() -> int:
try:
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
return max(1, min(n, 365))
except ValueError:
return 30
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
global _skill_slug, _logger_name
ensure_trace_for_process()
_skill_slug = skill_slug
_logger_name = logger_name
log = logging.getLogger(logger_name)
if log.handlers:
return
log.setLevel(_log_level_from_env())
path = get_skill_log_file_path()
fh = TimedRotatingFileHandler(
path,
when="midnight",
interval=1,
backupCount=_backup_count(),
encoding="utf-8",
delay=True,
)
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
log.addHandler(fh)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ("1", "true", "yes", "on"):
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fmt)
sh.addFilter(_SkillContextFilter())
log.addHandler(sh)
log.propagate = False
def get_skill_logger() -> logging.Logger:
if not _logger_name:
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
return logging.getLogger(_logger_name)
def attach_unified_file_handler(
log_path: str,
*,
skill_slug: str,
logger_name: str,
level: int = logging.DEBUG,
) -> logging.Logger:
global _skill_slug
ensure_trace_for_process()
_skill_slug = skill_slug
lg = logging.getLogger(logger_name)
lg.handlers.clear()
lg.setLevel(level)
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
fh = logging.FileHandler(log_path, encoding="utf-8")
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
lg.addHandler(fh)
lg.propagate = False
return lg

View File

@@ -5,6 +5,9 @@ skill-template CLI 入口。
复制为新技能后,请修改注释与常量,但保留当前分层结构: 复制为新技能后,请修改注释与常量,但保留当前分层结构:
cliargv→ service业务编排→ db持久化→ util通用工具 cliargv→ service业务编排→ db持久化→ util通用工具
jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit
不在技能仓库内 vendored。
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,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__":

View File

@@ -0,0 +1,26 @@
"""示例 adapter 四档 dispatch。
由 ``OPENCLAW_TEST_TARGET``(或 ``config.get``)决定档位。
复制本目录后把 ``example_adapter`` 改名为 ``<domain>_adapter``。
"""
from __future__ import annotations
import os
from .mock import MockAdapter
from .real_api import RealApiAdapter
from .real_rpa import RealRpaAdapter
from .sim_rpa import SimRpaAdapter
def get_adapter():
"""返回当前档位 adapter 实例。"""
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "simulator_rpa").strip().lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter()

View File

@@ -0,0 +1,37 @@
"""示例适配器数据契约与基类。
复制本目录为 ``<domain>_adapter/``,按 ADAPTER.md 实现各档位。
业务逻辑只依赖 ``base`` 里的接口,不感知 mock / sim_rpa / real_* 差异。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ExampleItem:
"""单条业务输入(示例)。"""
id: str
label: str
@dataclass
class ExampleResult:
"""批量操作结果(以批为单位返回)。"""
ok: bool
serial_no: Optional[str]
error_msg: Optional[str]
artifacts: Dict[str, Any] = field(default_factory=dict)
class AdapterBase:
"""四档 adapter 统一接口;子类实现 ``do_batch``。"""
name = "base"
def do_batch(self, target: str, items: List[ExampleItem]) -> ExampleResult:
raise NotImplementedError

View File

@@ -0,0 +1,24 @@
"""离线 mock 档位纯内存仿真CI / 单测必跑。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class MockAdapter(AdapterBase):
name = "mock"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
if not items:
return ExampleResult(
ok=False,
serial_no=None,
error_msg="empty batch",
artifacts={},
)
return ExampleResult(
ok=True,
serial_no=f"MOCK-{len(items)}",
error_msg=None,
artifacts={"mode": "mock", "target": target, "count": len(items)},
)

View File

@@ -0,0 +1,14 @@
"""真实 API 档位占位:有官方接口时在此实现。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class RealApiAdapter(AdapterBase):
name = "real_api"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
raise NotImplementedError(
"RealApiAdapter: 复制 example_adapter 后在此对接真实 API"
)

View File

@@ -0,0 +1,14 @@
"""真实 RPA 档位占位:无 API 时最后手段,谨慎实现。"""
from __future__ import annotations
from .base import AdapterBase, ExampleItem, ExampleResult
class RealRpaAdapter(AdapterBase):
name = "real_rpa"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
raise NotImplementedError(
"RealRpaAdapter: 复制 example_adapter 后在此实现生产界面 RPA"
)

View File

@@ -0,0 +1,108 @@
"""仿真 RPA 档位:演示 RpaVideoSession + launch_persistent_browser需浏览器默认开发档"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import anti_detect, artifacts, errors, launch_persistent_browser
from jiangchang_skill_core.rpa.human_login import wait_for_captcha_pass
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_data_dir, get_skill_root
from .base import AdapterBase, ExampleItem, ExampleResult
class SimRpaAdapter(AdapterBase):
name = "sim_rpa"
def do_batch(self, target: str, items: list[ExampleItem]) -> ExampleResult:
return asyncio.run(self._do_batch_async(target, items))
async def _do_batch_async(self, target: str, items: list[ExampleItem]) -> ExampleResult:
example_path = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example_path)
url = config.get("TARGET_BASE_URL") or target or "https://example.com"
batch_id = f"sim_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
data_dir = get_skill_data_dir()
profile_dir = os.path.join(data_dir, "browser-profile")
os.makedirs(profile_dir, exist_ok=True)
try:
from playwright.async_api import async_playwright
except ImportError:
return ExampleResult(
ok=False,
serial_no=None,
error_msg="playwright not installed",
artifacts={},
)
artifact_paths: dict = {}
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="技能仿真演示",
) as video:
video.add_step("正在打开目标页面")
try:
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
demo_text = items[0].label if items else "openclaw-demo"
video.step("正在填写演示内容")
await anti_detect.human_type(page, search, demo_text)
await anti_detect.human_delay_short()
video.step("正在等待人工验证(如有)")
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(
page, data_dir, batch_id, "captcha"
)
if shot:
artifact_paths["captcha"] = shot
return ExampleResult(
ok=False,
serial_no=None,
error_msg=errors.ERR_CAPTCHA_NEED_HUMAN,
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
video.step("演示任务完成")
await context.close()
except Exception as exc:
video.step("演示异常结束")
return ExampleResult(
ok=False,
serial_no=None,
error_msg=str(exc),
artifacts={**artifact_paths, **video.to_artifact_dict()},
)
arts = {"mode": "sim_rpa", "url": url, **artifact_paths, **video.to_artifact_dict()}
if video.output_video_path:
arts["video_path"] = video.output_video_path
return ExampleResult(
ok=True,
serial_no=f"SIM-{len(items)}",
error_msg=None,
artifacts=arts,
)

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""浏览器 RPA 最小用法示例(复制到新 skill 后按需改)。
演示标准流程:
1. config.ensure_env_file 首次落盘 .env
2. launch_persistent_browser 启动 stealth persistent context
3. 拟人操作mouse_wiggle / human_type / human_click
4. 验证码 HITL 等待
5. 失败 capture_failure 截图
运行(需系统 Chrome/Edge + playwright 包):
python scripts/service/example_browser_rpa.py
"""
from __future__ import annotations
import asyncio
import os
import sys
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import (
anti_detect,
artifacts,
errors,
launch_persistent_browser,
wait_for_captcha_pass,
)
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_data_dir, get_skill_root
async def demo() -> int:
example_path = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example_path)
url = config.get("TARGET_BASE_URL") or "https://example.com"
data_dir = get_skill_data_dir()
profile_dir = os.path.join(data_dir, "browser-profile")
os.makedirs(profile_dir, exist_ok=True)
batch_id = "demo"
try:
from playwright.async_api import async_playwright
except ImportError:
print("ERROR: playwright not installed")
return 1
async with async_playwright() as pw:
context = await launch_persistent_browser(
pw,
profile_dir=profile_dir,
headless=config.get_bool("OPENCLAW_BROWSER_HEADLESS"),
)
page = context.pages[0] if context.pages else await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60_000)
await anti_detect.human_mouse_wiggle(page)
search = page.locator('input[type="search"], input[name="q"]').first
if await search.count() > 0:
await anti_detect.human_type(page, search, "openclaw-rpa-demo")
await anti_detect.human_click(page, selector='input[type="search"]')
if not await wait_for_captcha_pass(
page,
timeout_sec=config.get_int("HUMAN_WAIT_TIMEOUT", 180),
):
shot = await artifacts.capture_failure(page, data_dir, batch_id, "captcha")
print(errors.ERR_CAPTCHA_NEED_HUMAN, shot or "")
return 1
print("OK browser RPA demo finished")
return 0
except Exception as exc:
shot = await artifacts.capture_failure(page, data_dir, batch_id, "error")
print(f"ERROR: {exc}", shot or "")
return 1
finally:
await context.close()
if __name__ == "__main__":
raise SystemExit(asyncio.run(demo()))

View File

@@ -1,7 +1,7 @@
"""兄弟技能 CLI 调用模板 """兄弟技能 CLI 调用工具
用工具:通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。 通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。
具体调用哪些兄弟技能,由具体业务 skill 决定(在 task_service.py 中按需 import 调用) 具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定
""" """
from __future__ import annotations from __future__ import annotations
@@ -50,16 +50,8 @@ def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, An
def get_sibling_main_path(skill_slug: str) -> str: def get_sibling_main_path(skill_slug: str) -> str:
"""获取兄弟技能的 main.py 绝对路径。 """获取兄弟技能的 main.py 绝对路径。
使用示例(在你的 task_service.py 中): 使用示例(在 task_service.py 中):
from service.sibling_bridge import get_sibling_main_path, call_sibling_json from service.sibling_bridge import get_sibling_main_path, call_sibling_json
# 调用名为 account-manager 的兄弟技能:
result = call_sibling_json("account-manager", ["list", "--limit", "10"]) result = call_sibling_json("account-manager", ["list", "--limit", "10"])
""" """
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py") return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
# ============================================================
# 复制本模板后,按需在你自己的 task_service.py 中调用上面的工具。
# 不要在本文件中硬编码具体兄弟技能函数(如 get_account_manager_main_path
# 那是发布类技能的特定需求,不属于通用模板。
# ============================================================

View File

@@ -0,0 +1,84 @@
"""任务运行辅助:录屏信息组装与 CLI 输出(通用模板,无业务逻辑)。"""
from __future__ import annotations
from typing import Any, Dict, Optional
def build_video_info(
video_summary: dict[str, Any],
compose_context_before: Optional[dict[str, Any]] = None,
compose_context_after: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""将 RpaVideoSession.summary() 转为 task log / CLI 可用的 video 块。"""
return {
"enabled": video_summary.get("enabled", False),
"path": video_summary.get("path"),
"capture_path": video_summary.get("capture_path"),
"record_log_path": video_summary.get("record_log_path"),
"warnings": list(video_summary.get("warnings") or []),
"voiceover_path": video_summary.get("voiceover_path"),
"music_path": video_summary.get("music_path"),
"audio_warnings": list(video_summary.get("audio_warnings") or []),
"compose_context": {
**(compose_context_before or {}),
"after_compose": compose_context_after or {},
},
}
def merge_video_into_result_summary(
payload: dict[str, Any],
video_info: Optional[dict[str, Any]],
) -> dict[str, Any]:
"""把 video artifact 字段写入 result_summary JSON 对象。"""
if not video_info:
return payload
payload = dict(payload)
payload["video"] = video_info
payload["video_path"] = video_info.get("path")
payload["raw_video"] = video_info.get("capture_path")
payload["video_log"] = video_info.get("record_log_path")
payload["video_warnings"] = list(video_info.get("warnings") or [])
payload["voiceover_path"] = video_info.get("voiceover_path")
payload["music_path"] = video_info.get("music_path")
payload["audio_warnings"] = list(video_info.get("audio_warnings") or [])
return payload
def _print_video_summary(video_block: Optional[dict[str, Any]]) -> None:
"""任务完成后在 CLI 打印录屏路径、日志与诊断信息。"""
if not isinstance(video_block, dict) or not video_block.get("enabled"):
return
record_log = video_block.get("record_log_path")
if record_log:
print(f"录屏日志:{record_log}")
path = video_block.get("path")
if path:
print(f"录屏路径:{path}")
else:
print("录屏路径:未生成")
warnings = video_block.get("warnings") or []
if warnings:
print(f"视频诊断:{'; '.join(str(w) for w in warnings)}")
capture = video_block.get("capture_path")
if capture:
print(f"原始录屏路径:{capture}")
voiceover = video_block.get("voiceover_path")
if voiceover:
print(f"旁白路径:{voiceover}")
music = video_block.get("music_path")
if music:
print(f"背景音乐:{music}")
audio_warnings = video_block.get("audio_warnings") or []
if audio_warnings:
print(f"音频诊断:{'; '.join(str(w) for w in audio_warnings)}")
compose_ctx = video_block.get("compose_context")
if isinstance(compose_ctx, dict):
print(
"视频合成上下文:"
f"media_assets_root={compose_ctx.get('media_assets_root', '')}; "
f"ffmpeg_path={compose_ctx.get('ffmpeg_path', '')}; "
f"background_music_issue={compose_ctx.get('background_music_issue') or ''}; "
f"background_music_sample_path={compose_ctx.get('background_music_sample_path', '')}"
)

View File

@@ -6,25 +6,81 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import sys import os
from typing import Optional import uuid
from typing import Any, Dict, Optional
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
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
from util.constants import SKILL_SLUG, SKILL_VERSION from service.task_run_support import (
_print_video_summary,
build_video_info,
merge_video_into_result_summary,
)
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.runtime_paths import get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso from util.timeutil import unix_to_iso
async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]:
"""最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。"""
batch_id = uuid.uuid4().hex[:12]
data_dir = get_skill_data_dir()
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="开始执行示例任务",
closing_title="示例任务执行完成",
) as video:
video.add_step("准备执行示例任务")
if target:
video.add_step(f"接收目标参数:{target}")
if input_id:
video.add_step(f"接收输入标识:{input_id}")
video.add_step("示例任务执行完成")
video_summary = video.summary()
video_info = build_video_info(video_summary, {}, {})
return 1, video_info
def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int:
"""通用任务执行入口模板。复制后请实现真实业务逻辑。""" """通用任务执行入口模板。复制后请实现真实业务逻辑。"""
_ = (target, input_id)
ok, reason = check_entitlement(SKILL_SLUG) ok, reason = check_entitlement(SKILL_SLUG)
if not ok: if not ok:
print(f"{reason}") print(f"{reason}")
return 1 return 1
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
_print_video_summary(video_info)
summary_payload = merge_video_into_result_summary(
{
"template_demo": True,
"target": target,
"input_id": input_id,
},
video_info,
)
tlr.save_task_log(
task_type="demo",
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="模板仓库未实现真实业务",
result_summary=json.dumps(summary_payload, ensure_ascii=False),
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return 1 return rc
def cmd_logs( def cmd_logs(
@@ -88,8 +144,43 @@ 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:
return 0 if sys.version_info >= (3, 10) else 1 runtime = collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=get_skill_root(),
)
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
def cmd_version() -> int: def cmd_version() -> int:

View 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

View File

@@ -1,5 +1,6 @@
"""技能标识版本(复制后请修改)。""" """技能标识版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug" SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.14" SKILL_VERSION = "1.0.14"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.0.14"

View File

@@ -30,8 +30,8 @@
从本模板复制出新技能仓库后,建议至少逐项确认: 从本模板复制出新技能仓库后,建议至少逐项确认:
- [ ] `python tests/run_tests.py -v` 能通过。 - [ ] `python tests/run_tests.py -v` 能通过(含 platform-kit 导入与无 vendored core 守护)
- [ ] `python scripts/main.py health` 能通过。 - [ ] `python scripts/main.py health` 能通过(输出 runtime diagnosticswarning 不导致非零退出)
- [ ] `python scripts/main.py version` 输出 JSON且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。 - [ ] `python scripts/main.py version` 输出 JSON且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。
- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。 - [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。
- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。 - [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。
@@ -39,6 +39,8 @@
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 - [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。 - [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏 - [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 如果技能有**特有** Python 三方依赖,`requirements.txt` 已声明且版本约束合理尽量收窄范围公共依赖platform-kit、playwright由宿主共享 runtime 提供,**不要**写入技能 requirements。
- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 - [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
--- ---
@@ -70,8 +72,15 @@ python tests/run_tests.py test_cli_smoke
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` | | **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **CLAW_*/JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` | | 运行路径与 **CLAW_*/JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` | | ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``publish_logs`` 骨架冒烟 | ``test_db_smoke.py`` | | DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.py`` |
| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) | | **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) |
| **无 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`` |
复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。
### 1.3 数据隔离(``_support.IsolatedDataRoot`` ### 1.3 数据隔离(``_support.IsolatedDataRoot``

View File

@@ -39,6 +39,16 @@ def get_skill_root() -> str:
return _SKILL_ROOT return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.0.14"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch
return patch(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
return_value=version,
)
class IsolatedDataRoot: class IsolatedDataRoot:
""" """
在独立临时目录下模拟数据根,避免污染本机默认数据目录。 在独立临时目录下模拟数据根,避免污染本机默认数据目录。

View File

@@ -32,3 +32,32 @@ pytest tests/desktop/test_desktop_smoke.py -v -s
- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。 - **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。
若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2Edesktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。 若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2Edesktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。
## 模板选择指南
| 场景 | 用哪个模板 |
|---|---|
| 纯问答 skill不上传附件、不跑长流程 | `test_desktop_smoke.py.sample` |
| 需要上传附件 / RPA 长流程 / Agent 多轮工具调用 | `test_desktop_smoke_with_attachment.py.sample` |
录屏入口都用 `record_screencast.py.sample`,里面 `TEST_FILE_NAME` 常量切换测试文件。
## 已知 SDK 坑(来自 disburse-payroll-icbc 实战)
1. **`client.send_file()` 在 v2.0.17+ 抛 NotImplementedError**——附件场景必须用
`jiangchang_desktop_sdk.e2e_helpers.drop_file_into_composer`DataTransfer 拖拽)。
2. **`client.ask()` 内部 `user_msg_index<0` 死循环**——如果匠厂启动慢 / gateway 握手有
延迟,导致 15s 内没抓到 user 节点ask() 会锁死后续 900s 等待。建议长流程 / 附件场景
绕开 ask(),用 `jiangchang_desktop_sdk.e2e_helpers` 里的 `send_prompt_via_composer`
+ `wait_for_user_message` + `wait_for_agent_complete` 自己控时。
3. **字幕关键词建议用 ASCII**——虽然 platform-kit 已修了 subprocess 编码乱码,
保险起见字幕脚本里关键词用 `step: 1/` 这种 ASCII 锚而非中文短语。
4. **`client.bring_to_front()` 已升级**——v?? 起会 ctypes ShowWindow(SW_MAXIMIZE) +
SetForegroundWindow能真正铺满桌面。dev 模式下也能用(不依赖 jiangchang:// 协议)。
## 录屏新能力platform-kit v??
- `run_screencast(..., activate_window=True)` 自动激活+最大化匠厂窗口
- `run_screencast(..., monitor_index=1)` 多显示器场景显式录主屏
- 字幕脚本支持三元组 `(keyword, text, duration)` 给个别字幕指定独立时长,
覆盖长等待场景中间没日志输出的"字幕真空"

View File

@@ -2,11 +2,13 @@
""" """
可选桌面 E2Epytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。 可选桌面 E2Epytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。
查找顺序(与 content-manager 对齐思路,去掉业务仓专属路径假设) 查找顺序:
1. 已 pip install 则直接 import 1. 已 pip install 则直接 import
2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src 2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src(旧版布局)
3. 自本文件上溯skill 根 → workspace 根 → OpenClaw 根,拼 jiangchang-platform-kit/sdk/.../src 3. 环境变量 JIANGCHANG_KIT_ROOT → src新版 platform-kit 布局)
4. 都找不到则 pytest.exit 给出可操作的排障说明。 4. 自本文件上溯skill 根 → workspace 根 → OpenClaw 根,拼路径;
5. 兜底 D:\\AI\\jiangchang-platform-kit\\src若存在
6. 都找不到则 pytest.exit 给出可操作的排障说明。
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,12 +33,22 @@ def _bootstrap_jiangchang_desktop_sdk() -> None:
candidates = [] candidates = []
if env_kit_root: if env_kit_root:
candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src")) candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src"))
candidates.append(os.path.join(env_kit_root, "src"))
candidates.append( candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
) )
candidates.append( candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
) )
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "src")
)
candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "src")
)
default_kit_src = r"D:\AI\jiangchang-platform-kit\src"
if os.path.isdir(default_kit_src):
candidates.append(default_kit_src)
tried = [] tried = []
for cand in candidates: for cand in candidates:
@@ -49,10 +61,10 @@ def _bootstrap_jiangchang_desktop_sdk() -> None:
pytest.exit( pytest.exit(
"无法定位 jiangchang_desktop_sdk。请任选一种方式解决\n" "无法定位 jiangchang_desktop_sdk。请任选一种方式解决\n"
" 1) pip install -e <path-to>/jiangchang-platform-kit/sdk/jiangchang-desktop-sdk\n" " 1) pip install -e <path-to>/jiangchang-platform-kit\n"
" 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n" " 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n"
" 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置,例如:\n" " SDK 新版位置:<root>\\src旧版<root>\\sdk\\jiangchang-desktop-sdk\\src\n"
f" {workspace_root}/jiangchang-platform-kit/ 或 {open_claw_root}/jiangchang-platform-kit/\n" " 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n"
"已尝试的候选路径:\n - " + "\n - ".join(tried), "已尝试的候选路径:\n - " + "\n - ".join(tried),
returncode=4, returncode=4,
) )

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""
Screencast 录制入口(模板)。
启用方式:
1. 复制为 record_screencast.py
2. 修改 SKILL_SLUG 和 SUBTITLE_SCRIPT
3. 决定 test_file 指向哪个测试(默认 test_desktop_smoke.py
有附件场景的用 test_desktop_smoke_with_attachment.py
4. 运行python tests/desktop/record_screencast.py
依赖:
1. pip install mss
2. 系统已安装 ffmpeg 并在 PATH 中
3. jiangchang-platform-kit 可推断路径(见 _bootstrap 逻辑)
4. MEDIA_ASSETS_ROOT 环境变量指向 media-assets 仓库(或使用默认路径)
平台 kit v?? 之后 run_screencast 自动激活并最大化匠厂窗口、支持指定显示器、
字幕支持每条独立时长。本模板已经利用这些能力,新 skill 复制后无需再内嵌
ctypes 激活代码。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _bootstrap_screencast() -> None:
"""将 jiangchang-platform-kit/tools 加入 sys.path使 screencast 包可导入。"""
here = Path(__file__).resolve()
skill_root = here.parents[2]
workspace_root = skill_root.parent
open_claw_root = workspace_root.parent
candidates = [
workspace_root / "jiangchang-platform-kit" / "tools",
open_claw_root / "jiangchang-platform-kit" / "tools",
Path(r"D:\AI\jiangchang-platform-kit") / "tools",
]
env_kit = os.environ.get("JIANGCHANG_KIT_ROOT", "")
if env_kit:
candidates.insert(0, Path(env_kit) / "tools")
for cand in candidates:
if (cand / "screencast" / "__init__.py").exists():
if str(cand) not in sys.path:
sys.path.insert(0, str(cand))
return
raise RuntimeError(
"找不到 jiangchang-platform-kit/tools/screencast。\n"
"请设置 JIANGCHANG_KIT_ROOT 环境变量,或将 platform-kit 放在 workspace "
"同级目录。"
)
_bootstrap_screencast()
from screencast import run_screencast # noqa: E402
# ============================================================
# ↓↓↓ 复制后只需修改下面这些 ↓↓↓
# ============================================================
SKILL_SLUG = "your-skill-slug" # 替换为本技能 slug
# 字幕脚本设计要点(来自 disburse-payroll-icbc 实战经验):
# 1. **关键词全用 ASCII**:保险起见绕开 Windows cp936/UTF-8 编码偶发问题。
# 虽然 platform-kit 已修了 subprocess 编码ASCII 关键词仍是最稳的。
# 2. **用 "step: N/X" 模式作触发锚**:在测试代码里加 _logger.info("step: 1/5 ...")
# 日志作为字幕触发点,能精确控制字幕出现时机,比依赖 SDK 内部日志更可靠。
# 3. **关键词每条只触发一次**,所以"step: 1/" 这种唯一前缀比"准备数据"更稳。
# 4. **三元组支持每条独立时长**(关键词, 文案, 显示秒数)
# long wait 场景可以让某条字幕停留更久(如 30s覆盖中间无日志输出的等待期。
SUBTITLE_SCRIPT = [
# (keyword, text) 二元组用默认时长5s
("ensure_app_running", "启动匠厂智能体平台"),
("wait_gateway_ready", "网关就绪"),
("step: 1/", "准备测试数据"),
("step: 2/", "执行技能操作"),
# (keyword, text, duration) 三元组指定独立时长
("step: 3/", "技能在后端持续执行,请稍候...", 30.0),
("step: 4/", "结果汇总返回"),
("PASSED", "测试通过 ✓"),
]
# 测试文件选择(按需切换):
# - test_desktop_smoke.py: 纯问答 skill 用这个
# - test_desktop_smoke_with_attachment.py: 需要上传附件的 skill 用这个
TEST_FILE_NAME = "test_desktop_smoke.py"
# 音乐子目录(按 skill 风格选):
# - "music/corporate": 商务严肃(金融、办公场景)
# - "music/calm": 舒缓(流程演示、配置类)
# - "music/upbeat": 活力(成功汇报、营销视频)
# - "music"(默认): 三个子目录混选
MUSIC_SUBDIR = "music/corporate"
# ============================================================
if __name__ == "__main__":
_here = Path(__file__).resolve()
test_file = str(_here.parent / TEST_FILE_NAME)
output_dir = str(_here.parent / "artifacts" / "recordings")
run_screencast(
skill_slug=SKILL_SLUG,
subtitle_script=SUBTITLE_SCRIPT,
pytest_args=[test_file, "-v", "-s"],
output_dir=output_dir,
music_subdir=MUSIC_SUBDIR,
# platform-kit 自动激活+最大化窗口Windows ctypes / 其他系统协议),
# 想关掉传 activate_window=False
# monitor_index=1 显式录主屏(多显示器场景推荐)
)

View File

@@ -1,12 +1,29 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample 可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample—— **简单问答场景**
启用方式: 启用方式:
复制本文件为 test_desktop_smoke.py按目标技能修改提问文案与断言。 复制本文件为 test_desktop_smoke.py按目标技能修改提问文案与断言。
运行pytest tests/desktop/test_desktop_smoke.py -v -s 运行pytest tests/desktop/test_desktop_smoke.py -v -s
依赖pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。 依赖pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。
【何时用此模板】
- 技能只需要发一句话给 Main Agent等回复做断言纯问答
- 不涉及上传附件 / 不涉及 RPA 长流程
【何时不要用此模板,改用 test_desktop_smoke_with_attachment.py.sample】
- 需要上传文件Excel / PDF / 图片)作为附件
- 技能内部跑 RPA / 长流程(> 2 分钟),需要精细等待
【已知 SDK 限制(来自 disburse-payroll-icbc 实战)】
- client.ask() 内部对"找到 user 消息节点"只给 15 秒窗口,找不到就锁死
user_msg_index=-1后续等待 assistant 永远 return None。如果你的场景里
匠厂启动较慢 / gateway 握手有延迟client.ask() 可能假死到 timeout。
此模板仍用 ask(),因为它对**简单场景**够用。复杂场景请用 _with_attachment.py.sample
里的"绕开 ask()"模式。
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError**绝不要调用它**。
需要附件请用 _with_attachment.py.sample 里的拖拽方案。
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample—— **附件上传 + 长流程场景**。
启用方式:
1. 复制为 test_desktop_smoke_with_attachment.py
2. 修改 SKILL_SLUG / PROMPT / 断言为你技能的真实业务
3. 准备样例附件,或动态生成(参考 _prepare_sample_attachment
4. 运行pytest tests/desktop/test_desktop_smoke_with_attachment.py -v -s
依赖:
- pytest / jiangchang_desktop_sdk / 匠厂桌面宿主已就绪
- 通过 conftest.py 自动 bootstrapjiangchang_desktop_sdk.e2e_helpers 模块在 sys.path
【为什么用此模板而不是 test_desktop_smoke.py.sample】
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError必须用拖拽方式
- client.ask() 在 user_idx<0 时会锁死(已知 SDK 缺陷),本模板绕开 ask()
在测试侧自己用 jiangchang_desktop_sdk.e2e_helpers 做等待
【字幕配合】
- 关键节点都打了 _logger.info("step: N/7 ..."),对应 record_screencast.py 的
SUBTITLE_SCRIPT 用 step: 关键词触发字幕
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import pytest
from jiangchang_desktop_sdk import JiangchangDesktopClient
from jiangchang_desktop_sdk.e2e_helpers import (
drop_file_into_composer,
send_prompt_via_composer,
wait_for_agent_complete,
wait_for_attachment_ready,
wait_for_composer_enabled,
wait_for_user_message,
)
# ============================================================
# ↓↓↓ 复制后修改这些 ↓↓↓
# ============================================================
SKILL_SLUG = "your-skill-slug"
PROMPT = (
"请使用 /your-skill-slug 处理我刚才上传的文件。"
"完成后请用清单方式汇总:[改成你技能要求的字段]。"
)
# 业务断言:根据你的技能输出修改
EXPECTED_KEYWORDS_IN_REPLY = ["关键词1", "关键词2"]
# ============================================================
_logger = logging.getLogger("desktop-e2e-with-attachment-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
def _prepare_sample_attachment(tmp_path: Path) -> Path:
"""在 tmp_path 下生成一个样例附件(按 skill 真实需要修改)。
示例:生成一个 5 行的 xlsx。你的 skill 可能需要 CSV / PDF / 图片等其他格式。
"""
from openpyxl import Workbook # noqa: WPS433
xlsx = tmp_path / "sample.xlsx"
wb = Workbook()
ws = wb.active
ws.append(["列1", "列2", "列3"])
for i in range(1, 6):
ws.append([f"值{i}A", f"值{i}B", i * 100])
wb.save(str(xlsx))
return xlsx
@pytest.fixture
def client(request):
c = JiangchangDesktopClient()
_logger.info("ensure_app_running")
c.ensure_app_running(wait_timeout_s=45)
c.bring_to_front()
c.wait_gateway_ready(timeout_ms=30000)
yield c
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = c.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("failure snapshot: %s", paths)
finally:
c.disconnect()
def test_desktop_with_attachment(client: JiangchangDesktopClient, tmp_path: Path) -> None:
_logger.info("step: 1/7 准备样例附件")
attachment_path = _prepare_sample_attachment(tmp_path)
client.new_task()
page = client.get_page()
client.bring_to_front()
_logger.info("step: 2/7 拖拽附件到聊天框")
drop_file_into_composer(page, attachment_path)
_logger.info("step: 3/7 等附件 stage-buffer 完成")
wait_for_attachment_ready(page, attachment_path.name)
wait_for_composer_enabled(page)
client.bring_to_front()
_logger.info("step: 4/7 键入并发送指令")
send_prompt_via_composer(page, PROMPT)
_logger.info("step: 5/7 Main Agent 接收,等待路由到技能")
user_idx = wait_for_user_message(page, PROMPT, timeout_s=60.0)
_logger.info("step: 6/7 技能在后端执行(可能 2-3 分钟)")
client.bring_to_front()
reply = wait_for_agent_complete(
page, user_idx,
overall_timeout_s=1200.0,
stable_seconds=5.0,
)
_logger.info("step: 7/7 校验回复内容")
_logger.info("reply length=%d", len(reply))
assert reply, "assistant 回复为空"
for kw in EXPECTED_KEYWORDS_IN_REPLY:
assert kw in reply, f"期望关键词 {kw!r} 未出现在回复中;片段:{reply[:400]!r}"

View File

@@ -8,7 +8,9 @@ import os
import unittest import unittest
from contextlib import redirect_stderr, redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from _support import IsolatedDataRoot from _support import IsolatedDataRoot, platform_kit_version_patch
from jiangchang_skill_core import config
# scripts/ 已由 _support 注入 sys.path # scripts/ 已由 _support 注入 sys.path
from cli.app import main from cli.app import main
@@ -27,10 +29,23 @@ class TestCliSmoke(unittest.TestCase):
self.assertIn("health", out) self.assertIn("health", out)
def test_health_zero(self) -> None: def test_health_zero(self) -> None:
buf = io.StringIO() old_record = os.environ.get("OPENCLAW_RECORD_VIDEO")
with redirect_stdout(buf), redirect_stderr(io.StringIO()): os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
rc = main(["health"]) try:
self.assertEqual(rc, 0) 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.assertIn("health:", out)
self.assertIn("python_executable:", out)
self.assertIn("platform_kit_version:", out)
self.assertIn("jiangchang_skill_core_file:", out)
finally:
if old_record is None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
os.environ["OPENCLAW_RECORD_VIDEO"] = old_record
def test_version_json_and_matches_constants_slug(self) -> None: def test_version_json_and_matches_constants_slug(self) -> None:
buf = io.StringIO() buf = io.StringIO()
@@ -58,17 +73,25 @@ class TestCliSmoke(unittest.TestCase):
self.assertIn("数字", buf.getvalue()) self.assertIn("数字", buf.getvalue())
def test_run_placeholder_returns_nonzero_without_network(self) -> None: def test_run_placeholder_returns_nonzero_without_network(self) -> None:
"""占位 run不验证业务成功,仅确认模板提示且退出码非 0无 AUTH_BASE 时不发 HTTP""" """占位 runRpaVideoSession 示范 + 模板提示OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg"""
old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None) old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None)
old_record = os.environ.get("OPENCLAW_RECORD_VIDEO")
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
try: try:
buf = io.StringIO() with IsolatedDataRoot(user_id="_cli_run"):
with redirect_stdout(buf), redirect_stderr(io.StringIO()): config.reset_cache()
rc = main(["run"]) buf = io.StringIO()
self.assertNotEqual(rc, 0) with redirect_stdout(buf), redirect_stderr(io.StringIO()):
self.assertIn("模板", buf.getvalue()) rc = main(["run"])
self.assertNotEqual(rc, 0)
self.assertIn("模板", buf.getvalue())
finally: finally:
if old_auth is not None: if old_auth is not None:
os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth
if old_record is None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
os.environ["OPENCLAW_RECORD_VIDEO"] = old_record
if __name__ == "__main__": if __name__ == "__main__":

View 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()
with open(example, encoding="utf-8") as f:
example_text = f.read()
self.assertIn("OPENCLAW_TEST_TARGET=mock", example_text)
self.assertEqual(config.get("OPENCLAW_TEST_TARGET"), "mock")
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()

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""文档与 pytest 配置守护:防止模板标准退化。"""
from __future__ import annotations
import configparser
import os
import unittest
from _support import get_skill_root
class TestDocsStandards(unittest.TestCase):
def _read(self, rel_path: str) -> str:
path = os.path.join(get_skill_root(), rel_path)
with open(path, encoding="utf-8") as f:
return f.read()
def test_rpa_md_covers_video_and_playwright_standards(self) -> None:
text = self._read("development/RPA.md")
self.assertIn("title", text)
self.assertIn("closing_title", text)
self.assertIn("中文", text)
self.assertIn("--no-sandbox", text)
self.assertIn("--disable-blink-features=AutomationControlled", text)
self.assertIn("RpaVideoSession", text)
self.assertIn("1.0.14", text)
def test_adapter_md_covers_four_tiers_and_allow_flags(self) -> None:
text = self._read("development/ADAPTER.md")
for marker in (
"mock",
"simulator_rpa",
"real_api",
"real_rpa",
"ALLOW_REAL_API",
"ALLOW_REAL_RPA",
"ALLOW_WRITE_ACTIONS",
"sibling_bridge",
):
self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}")
def test_cli_md_mentions_shared_python_runtime(self) -> None:
text = self._read("references/CLI.md")
self.assertIn("python-runtime", text)
self.assertIn("uv run python", text)
def test_testing_md_mentions_pytest_txt_collection_guard(self) -> None:
text = self._read("development/TESTING.md")
self.assertIn(".txt", text)
self.assertIn("pytest.ini", text)
def test_docs_do_not_claim_1_0_11_as_current_standard(self) -> None:
for rel in (
"SKILL.md",
"development/RUNTIME.md",
"development/RPA.md",
"development/CONFIG.md",
"development/TESTING.md",
):
text = self._read(rel)
self.assertNotIn(
">=1.0.11",
text.replace(" ", ""),
msg=f"{rel} still claims 1.0.11 as current minimum",
)
class TestPytestIniCollection(unittest.TestCase):
def test_pytest_ini_exists_and_limits_python_files(self) -> None:
ini_path = os.path.join(get_skill_root(), "pytest.ini")
self.assertTrue(os.path.isfile(ini_path), "pytest.ini must exist at repo root")
parser = configparser.ConfigParser()
parser.read(ini_path, encoding="utf-8")
python_files = parser.get("pytest", "python_files", fallback="")
self.assertIn("test_*.py", python_files)
self.assertIn("*_test.py", python_files)
if __name__ == "__main__":
unittest.main()

View File

@@ -10,6 +10,19 @@ import unittest
from _support import IsolatedDataRoot, get_skill_root from _support import IsolatedDataRoot, get_skill_root
import importlib.metadata as metadata
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
def _platform_kit_meets_template_minimum() -> bool:
try:
installed = metadata.version("jiangchang-platform-kit")
except metadata.PackageNotFoundError:
return False
return version_ge(installed, PLATFORM_KIT_MIN_VERSION)
def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]: def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]:
root = get_skill_root() root = get_skill_root()
@@ -30,6 +43,10 @@ def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess
class TestEntrypointSubprocess(unittest.TestCase): class TestEntrypointSubprocess(unittest.TestCase):
@unittest.skipUnless(
_platform_kit_meets_template_minimum(),
f"installed jiangchang-platform-kit below template minimum {PLATFORM_KIT_MIN_VERSION}",
)
def test_health_exit_zero(self) -> None: def test_health_exit_zero(self) -> None:
with IsolatedDataRoot(): with IsolatedDataRoot():
cp = _run_main(["health"]) cp = _run_main(["health"])

View 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()

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""防回归:公共能力必须从共享 platform-kit 导入,而非技能 vendored 副本。"""
from __future__ import annotations
import importlib.metadata as metadata
import os
import re
import sys
import unittest
from _support import get_scripts_dir, get_skill_root
def _parse_platform_kit_min_version(skill_md_text: str) -> str:
parts = skill_md_text.split("---", 2)
if len(parts) < 2:
raise ValueError("missing frontmatter")
m = re.search(
r"platform_kit_min_version:\s*[\"']?([^\"'\s]+)[\"']?",
parts[1],
)
if not m:
raise ValueError("platform_kit_min_version not found")
return m.group(1).strip()
def _requirement_dependency_lines(req_text: str) -> list[str]:
return [
line.strip()
for line in req_text.splitlines()
if line.strip() and not line.strip().startswith("#")
]
class TestPlatformImportSource(unittest.TestCase):
def test_jiangchang_skill_core_not_from_skill_scripts(self) -> None:
scripts_dir = os.path.abspath(get_scripts_dir())
skill_root = os.path.abspath(get_skill_root())
vendored = os.path.join(scripts_dir, "jiangchang_skill_core")
self.assertFalse(
os.path.isdir(vendored),
f"vendored package must not exist: {vendored}",
)
import jiangchang_skill_core
import jiangchang_skill_core.rpa.video_session as video_session
core_file = os.path.abspath(jiangchang_skill_core.__file__)
video_file = os.path.abspath(video_session.__file__)
self.assertNotIn(
scripts_dir.lower(),
core_file.lower(),
msg=f"jiangchang_skill_core loaded from skill scripts: {core_file}",
)
self.assertNotIn(
skill_root.lower(),
core_file.lower(),
msg=f"jiangchang_skill_core loaded from skill tree: {core_file}",
)
self.assertNotIn(
scripts_dir.lower(),
video_file.lower(),
msg=f"video_session loaded from skill scripts: {video_file}",
)
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
self.assertTrue(callable(RpaVideoSession))
def test_skill_tree_load_issue_helper(self) -> None:
from jiangchang_skill_core import is_jiangchang_skill_core_from_skill_tree
skill_root = os.path.abspath(get_skill_root())
fake = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py")
self.assertTrue(
is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=fake)
)
self.assertFalse(
is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root, core_file=sys.executable
)
)
def test_platform_kit_min_version_is_1_0_14(self) -> None:
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.0.14")
md_path = os.path.join(get_skill_root(), "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
self.assertEqual(_parse_platform_kit_min_version(md), "1.0.14")
req_path = os.path.join(get_skill_root(), "requirements.txt")
with open(req_path, encoding="utf-8") as f:
req = f.read()
dep_lines = _requirement_dependency_lines(req)
dep_body = "\n".join(dep_lines).lower()
self.assertNotIn("jiangchang-platform-kit", dep_body)
self.assertNotIn("playwright", dep_body)
try:
installed = metadata.version("jiangchang-platform-kit")
except metadata.PackageNotFoundError:
self.skipTest("jiangchang-platform-kit not installed in current interpreter")
if not version_ge(installed, PLATFORM_KIT_MIN_VERSION):
self.skipTest(
f"installed jiangchang-platform-kit {installed!r} < required "
f"{PLATFORM_KIT_MIN_VERSION!r}; upgrade shared runtime to enforce this check"
)
self.assertTrue(
version_ge(installed, PLATFORM_KIT_MIN_VERSION),
msg=f"installed {installed!r} < required {PLATFORM_KIT_MIN_VERSION!r}",
)
def test_collect_runtime_diagnostics_importable(self) -> None:
from jiangchang_skill_core import collect_runtime_diagnostics
self.assertTrue(callable(collect_runtime_diagnostics))
def test_main_import_smoke(self) -> None:
scripts_dir = get_scripts_dir()
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
from cli.app import build_parser
parser = build_parser()
self.assertIsNotNone(parser)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""Architecture guard: runtime diagnostics must come from platform-kit."""
from __future__ import annotations
import os
import unittest
from _support import get_scripts_dir, get_skill_root
_FORBIDDEN_DEFINITIONS = (
"class RuntimeDiagnostics",
"class RuntimeIssue",
"def collect_runtime_diagnostics",
"def format_runtime_health_lines",
"def runtime_diagnostics_dict",
"def _is_git_lfs_pointer",
"def _is_usable_audio_file",
"def _probe_background_music",
)
class TestRuntimeDiagnosticsIntegration(unittest.TestCase):
def test_no_vendored_jiangchang_skill_core(self) -> None:
vendored = os.path.join(get_scripts_dir(), "jiangchang_skill_core")
self.assertFalse(os.path.isdir(vendored), vendored)
def test_runtime_diagnostics_module_removed(self) -> None:
legacy = os.path.join(get_scripts_dir(), "util", "runtime_diagnostics.py")
self.assertFalse(os.path.isfile(legacy), legacy)
def test_util_py_files_do_not_reimplement_runtime_diagnostics(self) -> None:
util_dir = os.path.join(get_scripts_dir(), "util")
for name in os.listdir(util_dir):
if not name.endswith(".py"):
continue
path = os.path.join(util_dir, name)
with open(path, encoding="utf-8") as f:
text = f.read()
for forbidden in _FORBIDDEN_DEFINITIONS:
self.assertNotIn(
forbidden,
text,
msg=f"{name} must not define {forbidden}",
)
def test_task_service_imports_platform_kit_diagnostics(self) -> None:
path = os.path.join(get_scripts_dir(), "service", "task_service.py")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("from jiangchang_skill_core import", text)
self.assertIn("collect_runtime_diagnostics", text)
self.assertIn("format_runtime_health_lines", text)
self.assertIn("PLATFORM_KIT_MIN_VERSION", text)
self.assertNotIn("from util.runtime_diagnostics import", text)
def test_collect_runtime_diagnostics_callable_from_platform_kit(self) -> None:
from jiangchang_skill_core import collect_runtime_diagnostics
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG
from util.runtime_paths import get_skill_root
diag = collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=get_skill_root(),
)
self.assertEqual(diag.skill_slug, SKILL_SLUG)
self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""Docs guard: template must promote shared runtime, not vendored jiangchang_skill_core."""
from __future__ import annotations
import os
import unittest
from _support import get_skill_root
DOC_PATHS = (
"README.md",
"SKILL.md",
"development/RUNTIME.md",
"references/CLI.md",
"development/DEVELOPMENT.md",
"development/RPA.md",
"development/CONFIG.md",
)
RUNTIME_DOC_PATHS = tuple(p for p in DOC_PATHS if p != "README.md")
FORBIDDEN_PHRASES = (
"scripts/jiangchang_skill_core/:运行时",
"运行时与统一日志副本",
"vendor 源码在 scripts/jiangchang_skill_core",
"模板通过 vendor 拷贝引用",
"vendor 自 platform-kit",
"模板 vendor 拷贝在 scripts/jiangchang_skill_core",
"模板默认声明 `jiangchang-platform-kit",
"模板默认包含 `jiangchang-platform-kit",
"默认已含 `jiangchang-platform-kit",
"保留仅含 `jiangchang-platform-kit",
)
POSITIVE_MARKERS = (
"jiangchang-platform-kit",
"1.0.14",
"共享 runtime",
)
class TestTemplateRuntimeStandard(unittest.TestCase):
def _read(self, rel_path: str) -> str:
path = os.path.join(get_skill_root(), rel_path)
with open(path, encoding="utf-8") as f:
return f.read()
def test_guarded_docs_avoid_vendored_core_guidance(self) -> None:
for rel_path in DOC_PATHS:
text = self._read(rel_path)
for phrase in FORBIDDEN_PHRASES:
self.assertNotIn(
phrase,
text,
msg=f"{rel_path} must not contain {phrase!r}",
)
def test_guarded_docs_promote_shared_runtime_standard(self) -> None:
combined = "\n".join(self._read(p) for p in RUNTIME_DOC_PATHS)
for marker in POSITIVE_MARKERS:
self.assertIn(
marker,
combined,
msg=f"docs should mention {marker!r}",
)
self.assertTrue(
"不得 vendored" in combined or "不要 vendor" in combined,
msg="docs should state skills must not vendor jiangchang_skill_core",
)
def test_runtime_md_mentions_collect_runtime_diagnostics(self) -> None:
text = self._read("development/RUNTIME.md")
self.assertIn("collect_runtime_diagnostics", text)
def test_cli_md_mentions_health_runtime_diagnostics(self) -> None:
text = self._read("references/CLI.md")
self.assertIn("python_executable", text)
self.assertIn("platform_kit_version", text)
self.assertIn("jiangchang_skill_core_file", text)
self.assertIn("python-runtime", text)
if __name__ == "__main__":
unittest.main()

161
tests/test_video_service.py Normal file
View File

@@ -0,0 +1,161 @@
# -*- 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
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=1",
"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")
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)
if __name__ == "__main__":
unittest.main()

194
tools/highlight_answers.py Normal file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Highlight correct answer options in Excel quiz file."""
import re
import sys
from pathlib import Path
import openpyxl
from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock
# Option letter to index mapping
LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3}
IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
# Regex to find option boundaries: A/B/C/D followed by separator
OPTION_MARKER_RE = re.compile(
r"(?=[A-D](?:[\..、:\)]|\s))"
)
NORMAL_FONT = InlineFont(b=False, color="000000")
RED_BOLD_FONT = InlineFont(b=True, color="FF0000")
def parse_answer(answer_raw) -> set[int]:
"""Parse answer column into set of option indices (0=A, 1=B, ...)."""
if answer_raw is None:
return set()
s = str(answer_raw).strip()
if not s:
return set()
indices = set()
# Pure digits like "13" -> 1 and 3 (not 13)
if re.fullmatch(r"[1-4]+", s):
for ch in s:
indices.add(int(ch) - 1)
return indices
# Split by common separators
parts = re.split(r"[,;\s|]+", s)
for part in parts:
part = part.strip().upper()
if not part:
continue
if part in LETTER_TO_IDX:
indices.add(LETTER_TO_IDX[part])
elif re.fullmatch(r"[1-4]", part):
indices.add(int(part) - 1)
return indices
def find_option_spans(text: str) -> list[tuple[int, int, str]]:
"""Find spans for each A/B/C/D option in the options text."""
if not text or not isinstance(text, str):
return []
markers = []
for m in OPTION_MARKER_RE.finditer(text):
letter = text[m.start()]
markers.append((m.start(), letter))
if not markers:
return []
spans = []
for i, (start, letter) in enumerate(markers):
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
spans.append((start, end, letter))
return spans
def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str:
"""Build rich text with correct options in red bold."""
spans = find_option_spans(options_text)
if not spans:
return options_text
parts = []
pos = 0
for start, end, letter in spans:
if start > pos:
parts.append(TextBlock(NORMAL_FONT, options_text[pos:start]))
idx = LETTER_TO_IDX.get(letter)
font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT
parts.append(TextBlock(font, options_text[start:end]))
pos = end
if pos < len(options_text):
parts.append(TextBlock(NORMAL_FONT, options_text[pos:]))
return CellRichText(*parts)
def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook:
"""Load .xls file, converting via pandas/xlrd if needed."""
try:
import pandas as pd
xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str)
wb = openpyxl.Workbook()
# Remove default sheet
wb.remove(wb.active)
for sheet_name, df in xls_data.items():
ws = wb.create_sheet(title=str(sheet_name))
for r_idx, row in enumerate(df.itertuples(index=False), start=1):
for c_idx, val in enumerate(row, start=1):
if val is not None and str(val) != "nan":
ws.cell(row=r_idx, column=c_idx, value=str(val))
return wb
except Exception as e:
raise RuntimeError(f"Failed to read .xls file: {e}") from e
def find_header_columns(ws) -> tuple[int, int, int] | None:
"""Find header row and column indices for 题目选项 and 题目答案."""
for row_idx in range(1, min(ws.max_row + 1, 20)):
options_col = None
answer_col = None
for col_idx in range(1, ws.max_column + 1):
val = ws.cell(row=row_idx, column=col_idx).value
if val is None:
continue
s = str(val).strip()
if s == "题目选项":
options_col = col_idx
elif s == "题目答案":
answer_col = col_idx
if options_col is not None and answer_col is not None:
return row_idx, options_col, answer_col
return None
def process_sheet(ws, stats: dict) -> bool:
"""Process a single worksheet. Returns True if processed."""
header_info = find_header_columns(ws)
if header_info is None:
return False
header_row, options_col, answer_col = header_info
stats["sheets"] += 1
sheet_rows = 0
sheet_matches = 0
for row_idx in range(header_row + 1, ws.max_row + 1):
options_cell = ws.cell(row=row_idx, column=options_col)
answer_cell = ws.cell(row=row_idx, column=answer_col)
options_text = options_cell.value
if options_text is None:
continue
options_text = str(options_text).strip()
if not options_text:
continue
answer_indices = parse_answer(answer_cell.value)
if not answer_indices:
continue
sheet_rows += 1
sheet_matches += len(answer_indices)
rich = build_rich_text(options_text, answer_indices)
options_cell.value = rich
stats["rows"] += sheet_rows
stats["matches"] += sheet_matches
return True
def main():
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
if not src.exists():
print(f"ERROR: Source file not found: {src}")
sys.exit(1)
print(f"Reading: {src}")
wb = load_workbook_from_xls(src)
stats = {"sheets": 0, "rows": 0, "matches": 0}
for ws in wb.worksheets:
process_sheet(ws, stats)
wb.save(dst)
print(f"Saved: {dst}")
print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项")
if __name__ == "__main__":
main()

57
tools/verify_highlight.py Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Verify highlighted answer output file."""
from pathlib import Path
import openpyxl
dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx")
src = Path(r"D:\下载\安全知识竞赛题库(1).xls")
print(f"Source exists: {src.exists()}")
print(f"Output exists: {dst.exists()}")
wb = openpyxl.load_workbook(dst, rich_text=True)
ws = wb.active
print(f"Sheet: {ws.title}, rows={ws.max_row}, cols={ws.max_column}")
# Find header columns
header_row = None
options_col = None
answer_col = None
for row_idx in range(1, 20):
for col_idx in range(1, ws.max_column + 1):
val = ws.cell(row=row_idx, column=col_idx).value
if val and str(val).strip() == "题目选项":
options_col = col_idx
if val and str(val).strip() == "题目答案":
answer_col = col_idx
if options_col and answer_col:
header_row = row_idx
break
print(f"Header row={header_row}, options_col={options_col}, answer_col={answer_col}")
# Sample verification for first 5 rows with answers 1,2,3
samples = []
for row_idx in range(header_row + 1, ws.max_row + 1):
answer = ws.cell(row=row_idx, column=answer_col).value
if answer in ("1", "2", "3", 1, 2, 3):
cell = ws.cell(row=row_idx, column=options_col)
val = cell.value
runs = []
if hasattr(val, "__iter__") and not isinstance(val, str):
for block in val:
font = block.font
bold = font.b if font else None
color = font.color if font else None
runs.append((block.text, bold, color))
samples.append((row_idx, answer, runs))
if len(samples) >= 5:
break
for row_idx, answer, runs in samples:
print(f"\nRow {row_idx}, answer={answer}:")
for text, bold, color in runs:
marker = "RED_BOLD" if bold and color == "FF0000" else "normal"
print(f" [{marker}] {repr(text[:50])}")