Compare commits

...

17 Commits

Author SHA1 Message Date
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
5c18d308bb chore: bump version to 1.0.14 for release
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:08:13 +08:00
f0032b2201 docs: generalize skill-template references + add TESTING.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:01:15 +08:00
f3f59278b4 refactor: task_logs, task_service, run CLI; remove platform_playwright
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 10:00:37 +08:00
4730cd527d 继续完善测试模板 2026-05-03 18:08:57 +08:00
dd6236866e 新增完善测试模板 2026-05-03 17:52:52 +08:00
07fa0b0038 修正开发者ID 2026-04-19 18:19:51 +08:00
74 changed files with 4485 additions and 343 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# ── 运行模式(对应 adapter 四档,见 references/ADAPTER.md──
OPENCLAW_TEST_TARGET=simulator_rpa # 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 等人工超时(秒)

3
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
# 匠厂 技能开发模板 # 匠厂 技能开发模板(通用业务)
这是一个**规范化的新技能模板仓库**,用于复制出新的 skill 项目;它本身**不是业务 skill**。 这是一个**规范化的新技能模板仓库**,用于复制出新的 skill 项目;它本身**不是业务 skill**。
@@ -6,23 +6,25 @@
- 对齐当前规范 skill 的目录结构:`assets/``references/``scripts/``tests/``evals/` - 对齐当前规范 skill 的目录结构:`assets/``references/``scripts/``tests/``evals/`
- 对齐当前规范脚手架分层:`scripts/cli``scripts/db``scripts/service``scripts/util``scripts/jiangchang_skill_core` - 对齐当前规范脚手架分层:`scripts/cli``scripts/db``scripts/service``scripts/util``scripts/jiangchang_skill_core`
- 提供最小可运行入口:`python scripts/main.py health` / `version` - 提供最小可运行入口:`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` 结构 - 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/``optional/``skill_main.py` 结构
## 新技能使用步骤 ## 新技能使用步骤
1. 复制本目录为新的 skill 仓库。 1. 复制本目录为新的 skill 仓库。
2. 全局替换 `your-skill-slug``your-platform-key``您的技能显示名称``你的平台名` 等占位内容。 2. 全局替换 `your-skill-slug``your-platform-key``您的技能显示名称` 等占位内容。
3. 修改 `SKILL.md``references/``scripts/util/constants.py` 3. 修改 `SKILL.md``references/``scripts/util/constants.py`
4.`scripts/service/` 中补业务 service 与真正的发布/执行逻辑 4.`scripts/service/task_service.py` 中实现真实业务编排与外部系统对接
5. `python scripts/main.py health` `python scripts/main.py version` 做最小验证。 5. 运行 `python tests/run_tests.py -v`,再执行 `python scripts/main.py health``version` 做最小验证。
如果你的技能在平台里默认是非公开的(`access_scope = 0`),建议在 `SKILL.md``metadata.openclaw.developer_ids` 中填写开发者用户 ID 列表。这样发布后,平台会自动给这些开发者补可见权限,避免技能已发布但开发者自己在市场中看不到 如果你的技能在平台里默认是非公开的(`access_scope = 0`),建议在 `SKILL.md``metadata.openclaw.developer_ids` 中填写开发者用户 ID 列表。这样在正式发布后,平台会这些开发者补可见权限,避免技能已上架但开发者自己在市场中看不到
开发教程入口: 开发教程入口:
- <a href="references/REQUIREMENTS.md" target="_blank" rel="noopener noreferrer">需求文档模板</a>:给技术人员编写和查看研发需求的标准模板 - <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/DEVELOPMENT.md" target="_blank" rel="noopener noreferrer">开发教程</a>:给技术人员的完整开发步骤说明
- <a href="references/TESTING.md" target="_blank" rel="noopener noreferrer">测试开发指南</a>:默认套件、隔离数据根、档位开关与 FakeAdapter 怎么用
## 目录说明 ## 目录说明
@@ -32,8 +34,9 @@
| `assets/` | 示例输出与轻量 schema | | `assets/` | 示例输出与轻量 schema |
| `references/` | 面向用户与编排的文档模板 | | `references/` | 面向用户与编排的文档模板 |
| `scripts/` | 规范分层后的代码骨架 | | `scripts/` | 规范分层后的代码骨架 |
| `tests/` | 单元测试或最小回归测试 | | `tests/` | 单元测试与分层测试范式(默认根目录 unittest |
| `evals/` | 人工/半自动评估材料 | | `evals/` | 人工/半自动评估材料 |
| `requirements.txt` | 本技能 Python 三方依赖声明(由匠厂宿主安装到共享 runtime |
| `.github/workflows/release_skill.yaml` | 标准发布工作流 | | `.github/workflows/release_skill.yaml` | 标准发布工作流 |
| `release.ps1` | 对齐现有 skill 的发布脚本入口 | | `release.ps1` | 对齐现有 skill 的发布脚本入口 |
@@ -48,4 +51,14 @@ python scripts/main.py version
- 不要再往模板里引入旧式 `docs/``optional/` 目录。 - 不要再往模板里引入旧式 `docs/``optional/` 目录。
- 新技能若不需要某些目录,也建议先保留结构,再按实际业务填充内容。 - 新技能若不需要某些目录,也建议先保留结构,再按实际业务填充内容。
- `metadata.openclaw.developer_ids` 是发布元数据,不是用户展示文案。它的作用是让发布后的非公开技能自动授权给指定开发者查看。 - `metadata.openclaw.developer_ids` 是发布元数据,不是用户展示文案;用于非公开技能的开发者默认可见授权。
- **发起 `release.ps1` 之前**,务必确认 `python tests/run_tests.py -v` 已全部通过。
## Python 依赖requirements.txt
- 在技能**根目录**维护 `requirements.txt`,声明本技能特有的 Python 三方包。
- 匠厂宿主在**安装或更新**技能后,会将该文件中的依赖安装到共享 Python runtime
`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`Windows 示例:`D:\jiangchang-data\python-runtime\.venv`)。
- 平台通用依赖由宿主 `resources/jiangchang-python-runtime/pyproject.toml` 维护;**不要**把业务依赖写进宿主 pyproject。
- **Skill 业务代码不得自动 `pip install`**;缺依赖时应在 `health` / preflight 中给出清晰错误。
- **系统级组件**(如 Microsoft Visual C++ Redistributable**不要**写进 `requirements.txt`;仅在 `health` / preflight 中提示用户自行安装。

View File

@@ -1,7 +1,7 @@
--- ---
name: 技能开发模板(复制后请修改 name: 技能开发模板(通用业务版
description: "这是 OpenClaw 技能开发模板仓库,不直接作为业务技能发布。复制为新技能仓库后,按本模板替换 slug、名称、说明、CLI 子命令与 service 实现。" description: "OpenClaw 通用业务技能开发模板,覆盖任务执行、日志记录、外部系统对接、隔离测试等典型能力。复制后请按 references/DEVELOPMENT.md 完成定制。"
version: 1.0.13 version: 1.0.14
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -9,7 +9,7 @@ metadata:
emoji: "📦" emoji: "📦"
category: "通用" category: "通用"
developer_ids: developer_ids:
- 1032 - 10032
- 12428 - 12428
allowed-tools: allowed-tools:
- bash - bash
@@ -22,7 +22,7 @@ allowed-tools:
## 模板使用方式 ## 模板使用方式
1. 复制目录为你的新 skill 仓库。 1. 复制目录为你的新 skill 仓库。
2. 全局替换 `your-skill-slug``技能开发模板(复制后请修改` 等占位词。 2. 全局替换 `your-skill-slug``技能开发模板(通用业务版` 等占位词。
3.`references/CLI.md``scripts/` 分层与 `README.md` 的说明补业务逻辑。 3.`references/CLI.md``scripts/` 分层与 `README.md` 的说明补业务逻辑。
## 目录约定 ## 目录约定
@@ -38,11 +38,17 @@ python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py version python {baseDir}/scripts/main.py version
``` ```
## 运行依赖
- Python 运行环境由匠厂宿主注入共享 runtime`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 本技能**特有**的 Python 三方依赖请声明在根目录 `requirements.txt`;宿主安装/更新技能后会自动安装到共享 venv。
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装。
## 重要说明 ## 重要说明
- 复制后请同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION` - 复制后请同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
- 如技能无需持久化,可保留 `db/` 目录但不主动调用。 - 如技能无需持久化,可保留 `db/` 目录但不主动调用。
- `metadata.openclaw.developer_ids` 用于声明技能发布后的默认开发者可见用户 ID 列表。 - `metadata.openclaw.developer_ids` 用于声明技能发布后的默认开发者可见用户 ID 列表。
- 当技能在平台中 `access_scope = 0`(不公开)时,发布流程会把 `developer_ids` 中的用户自动补写到 `skill_user_access`,使这些开发者仍可在技能市场中查看该技能。 - 当技能在平台中 `access_scope = 0`(不公开)时,任务执行流程会把 `developer_ids` 中的用户自动补写到 `skill_user_access`,使这些开发者仍可在技能市场中查看该技能。
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id` - `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`
- 面向用户与编排的文档写在 `references/`,不要再新增旧式 `docs/` / `optional/` 结构。 - 面向用户与编排的文档写在 `references/`,不要再新增旧式 `docs/` / `optional/` 结构。

View File

@@ -3,5 +3,4 @@
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。
- 面向用户的介绍`references/README.md` 面向编排与用户文档`references/README.md``references/CLI.md``RUNTIME.md``SCHEMA.md`
- 面向编排/CLI 的细节见 `references/CLI.md``RUNTIME.md``SCHEMA.md`

View File

@@ -1,10 +1,12 @@
{ {
"id": 1, "id": 1,
"account_id": "demo_account_1", "task_type": "demo_task",
"article_id": 12, "target_id": "demo_target_1",
"article_title": "示例标题", "input_id": "demo_input_42",
"status": "published", "input_title": "示例任务输入标题",
"status": "success",
"error_msg": null, "error_msg": null,
"result_summary": "{\"processed\": 10, \"skipped\": 0}",
"created_at": "2026-04-01T10:00:00", "created_at": "2026-04-01T10:00:00",
"updated_at": "2026-04-01T10:00:00" "updated_at": "2026-04-01T10:00:00"
} }

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/publish-log-record.schema.json",
"title": "PublishLogRecord",
"description": "发布型技能 log-get 返回的单条日志对象模板",
"type": "object",
"required": ["id", "account_id", "article_id", "article_title", "status", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"account_id": { "type": ["string", "integer"] },
"article_id": { "type": "integer" },
"article_title": { "type": "string" },
"status": { "type": "string" },
"error_msg": { "type": ["string", "null"] },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},
"additionalProperties": true
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/task-log-record.schema.json",
"title": "TaskLogRecord",
"description": "通用业务技能 log-get 返回的单条任务日志对象模板",
"type": "object",
"required": ["id", "task_type", "status", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"task_type": { "type": "string" },
"target_id": { "type": ["string", "null"] },
"input_id": { "type": ["string", "null"] },
"input_title": { "type": ["string", "null"] },
"status": { "type": "string" },
"error_msg": { "type": ["string", "null"] },
"result_summary": { "type": ["string", "null"] },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},
"additionalProperties": true
}

115
references/ADAPTER.md Normal file
View File

@@ -0,0 +1,115 @@
# 适配器标准:真实/仿真 × API/RPA 四档模式
> 凡是"连接三方系统"ERP、CRM、SaaS、银行等的 skill都应采用本文的 **adapter 四档模式**。这样同一套业务逻辑可以在不同档位间切换开发用仿真、CI 用 mock、上线用真实互不影响。
## 为什么要分档
对接一个外部系统,工程上无非四种方式的组合:
| | API有接口 | RPA操作界面 |
|---|---|---|
| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 |
| **仿真系统** | `mock` 离线仿真 | `sim_rpa` 仿真平台界面 |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
- **sim_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑真实端到端流程但不碰生产,**默认开发档位**。
- **real_api**:有官方接口时**首选**(最稳、最快、最易维护)。
- **real_rpa**:没有 API 只能操作生产界面,**风险最高、放最后**。
> 推荐优先级:**real_api > sim_rpa > real_rpa**mock 永远保留做 CI。
## 目录骨架
业务逻辑只依赖 `base` 里定义的接口与数据契约,具体档位实现各自一个文件:
```
scripts/service/<domain>_adapter/
__init__.py # dispatch按档位返回对应 adapter 实例
base.py # 数据契约dataclass+ AdapterBase 接口NotImplementedError
mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPAPlaywright/pywinauto/uiautomator2
real_rpa.py # 真实系统 RPA占位谨慎实现
```
> 参考实现:`disburse-payroll-icbc/scripts/service/disburse_adapter/`(已有 `base.py` / `mock.py` / `icbc_simulator_rpa.py`)。
> 模板示例:`scripts/service/example_adapter/`(复制改名即用)。
### 引用方式
```python
from service.example_adapter import get_adapter
from service.example_adapter.base import ExampleItem
adapter = get_adapter() # 由 OPENCLAW_TEST_TARGET 决定档位
result = adapter.do_batch(
target="https://sandbox.jc2009.com",
items=[ExampleItem(id="1", label="demo")],
)
```
mock 档位纯离线验证:
```bash
set OPENCLAW_TEST_TARGET=mock
python -c "import sys; sys.path.insert(0,'scripts'); from service.example_adapter import get_adapter; from service.example_adapter.base import ExampleItem; r=get_adapter().do_batch('x',[ExampleItem('1','a')]); print(r.ok, r.serial_no)"
```
## base.py 写法(要点)
1. **数据契约用 `@dataclass`**:输入项(如 `PayrollItem`)、批量结果(如 `BatchResult{ok, serial_no, error_msg, artifacts}`)。
2. **接口方法抛 `NotImplementedError`**:所有档位实现同一签名。
3. **以"批"为单位返回**:真实 RPA 往往是整批提交,结果按批次返回,不要假设逐条。
4. **结果里带 `artifacts`**:截图/录屏/日志路径,便于排查。
```python
@dataclass
class XxxResult:
ok: bool
serial_no: Optional[str]
error_msg: Optional[str]
artifacts: Dict[str, Any] = field(default_factory=dict)
class AdapterBase:
name = "base"
def do_batch(self, target: str, items: list) -> XxxResult:
raise NotImplementedError
```
## 档位 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 "simulator_rpa").lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
return RealApiAdapter()
if target == "real_rpa":
return RealRpaAdapter()
return SimRpaAdapter() # 默认
```
> 实际可改为读 `.env` 里的运行模式(见 `CONFIG.md``OPENCLAW_TEST_TARGET` 作为进程级覆盖优先。
## 测试要求
- **mock 档必须能纯离线跑通**,进 CI。
- **每个 adapter 单测同一组用例**(契约测试),保证档位间行为一致。
- RPA 档的真实联网测试单独标记,不进默认 CI参考 `tests/desktop/`)。
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `CONFIG.md``.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

View File

@@ -1,4 +1,4 @@
# skill-template CLI 模板 # 通用业务技能模板 CLI 模板
`{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。 `{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。
@@ -9,28 +9,38 @@ python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py version python {baseDir}/scripts/main.py version
``` ```
## 若你的技能是发布型 ## 通用业务命令骨架
建议继续扩展这些子命令 模板默认提供以下命令(复制后按需保留或扩展)
```bash ```bash
python {baseDir}/scripts/main.py publish python {baseDir}/scripts/main.py run
python {baseDir}/scripts/main.py publish -a <账号id> -i <文章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 log-get <log_id> python {baseDir}/scripts/main.py log-get <log_id>
``` ```
含义:
- `run`:执行一次任务(具体语义由你的 skill 决定)
- `logs`:列出最近 N 条任务日志,可按 task_type / status / target 过滤
- `log-get`:按 log_id 查看单条任务日志JSON
## 若你的技能是发布类
可以把 `run` 命令对外别名为 `publish`,参数 `--target` 别名为 `--account-id`,参数 `--input-id` 保持不变。
## 若你的技能依赖兄弟技能 ## 若你的技能依赖兄弟技能
并列技能与 `{baseDir}` 同级时,兄弟技能路径写为: 使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 sibling_bridge.py 自身。
```bash ```bash
python {baseDir}/../content-manager/scripts/main.py ... # 假设 base 与兄弟技能同级
python {baseDir}/../account-manager/scripts/main.py ... python {baseDir}/../<sibling-skill-slug>/scripts/main.py ...
``` ```
## 模板约定 ## 模板约定
- 最小模板至少保留 `health` / `version` - 最小模板至少保留 `health` / `version`
- 发布型技能建议使用 `publish` / `logs` / `log-get` - 通用业务技能建议使用 `run` / `logs` / `log-get`
- 不要再用旧模板的 `scripts/skill_main.py` - 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名

99
references/CONFIG.md Normal file
View File

@@ -0,0 +1,99 @@
# 配置标准:`.env` 规范与首次落盘机制
> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`skill 首次运行自动落盘到用户数据目录,之后从那里读。**
## 核心原则
1. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
2. **首次运行自动 copy**`setup` / `doctor` / 首次 `run` 时,把 `.env.example` 复制到
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`**已存在则不覆盖**,保护用户改过的值)。
3. **三级优先级读取**`进程环境变量` > `数据目录/.env` > `.env.example 默认值`
4. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
## 标准配置项
每个 skill 的 `.env.example` 至少包含这些(按需增减):
```ini
# ── 运行模式(对应 adapter 四档,见 ADAPTER.md──
OPENCLAW_TEST_TARGET=simulator_rpa # 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=录屏+字幕+背景音+MP4成片RPA技能默认开见 RPA.md
OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认)
# ── 节流 / 超时 ──
STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒)
STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒)
HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒)
```
## 🚫 红线:敏感信息不进 `.env`
`.env` 只放**非敏感运行参数**模式、地址、开关、超时。密码、密钥、动态口令、token——
-**account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用;
- 真实值由宿主/用户通过环境变量注入,**不落任何文件、不进 git**。
- 参考 `disburse-payroll-icbc/scripts/cli/cmd_setup.py`:账号写入 account-manager密码只登记 `secret-ref`(如 `ICBC_SIM_PASSWORD`)。
`.gitignore` 必须包含:
```gitignore
.env
*.env.local
```
## 配置读取层(模板提供)
模板会提供一个统一 `config.py`,约定行为:
```python
config.get("TARGET_BASE_URL") # 三级优先级解析
config.get_bool("OPENCLAW_BROWSER_HEADLESS")
config.get_float("STEP_DELAY_MIN", 1.0)
config.ensure_env_file() # 首次把 .env.example copy 到数据目录
```
- 启动时调一次 `ensure_env_file()` 完成落盘。
- 所有业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`,便于统一管理与测试覆盖。
### 引用方式
实现位于 `jiangchang-platform-kit``jiangchang_skill_core.config`;模板 vendor 拷贝在 `scripts/jiangchang_skill_core/config.py`
```python
from jiangchang_skill_core import config
from util.constants import SKILL_SLUG
from util.runtime_paths import get_skill_root
import os
example = os.path.join(get_skill_root(), ".env.example")
config.ensure_env_file(SKILL_SLUG, example)
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS")
target = config.get("TARGET_BASE_URL")
timeout = config.get_int("HUMAN_WAIT_TIMEOUT", 180)
```
## doctor / setup 命令
每个 skill 应提供自检命令(参考 `disburse``test_doctor`
- `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

@@ -2,14 +2,14 @@
这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `skill-template` 后,可以**一步一步开发出一个新的 skill**。 这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `skill-template` 后,可以**一步一步开发出一个新的 skill**。
本文默认你开发的是当前最常见的一类技能 本文默认你开发的是当前最常见的一类业务 skill
- 有明确的 `scripts/main.py` CLI 入口 - 有明确的 `scripts/main.py` CLI 入口
- 可能需要读写本地 SQLite - 可能需要读写本地 SQLite(例如任务日志 `task_logs`
- 可能需要调用兄弟技能 - 可能需要调用兄弟技能或外部 HTTP / RPA
- 业务逻辑主要放在 `scripts/service/` - 业务逻辑主要放在 `scripts/service/`
如果你开发的是发布型 skill这个模板就是直接可用的起点 发布、对账、代发、报表分析等场景都只是业务特例;模板提供通用骨架,复制后按领域补齐实现即可
## 推荐 AI 开发工具 ## 推荐 AI 开发工具
@@ -103,6 +103,7 @@ your-skill/
├─ tests/ ├─ tests/
├─ .gitignore ├─ .gitignore
├─ README.md ├─ README.md
├─ requirements.txt
├─ release.ps1 ├─ release.ps1
└─ SKILL.md └─ SKILL.md
``` ```
@@ -145,25 +146,58 @@ scripts/
- `service/` - `service/`
作用:核心业务逻辑 作用:核心业务逻辑
比如发布流程、调用兄弟技能、浏览器自动化 比如任务编排、调用兄弟技能、外部 API、可选浏览器自动化
- `util/` - `util/`
作用:常量、日志、路径、时间工具、通用帮助函数 作用:常量、日志、路径、时间工具、通用帮助函数
- `jiangchang_skill_core/` - `jiangchang_skill_core/`
作用:运行时环境统一日志副本 作用:运行时环境统一日志、配置读取、RPA 共享库vendor 自 platform-kit
一般按现有规范技能复制,不要自己乱改结构 一般按现有规范技能复制,不要自己乱改结构
## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读三份标准**`references/RPA.md`(三端范式与反反爬)、`references/CONFIG.md``.env` 落盘与读取)、`references/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
```
vendor 源码在 `scripts/jiangchang_skill_core/rpa/`(与 `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 三方依赖。
- 匠厂宿主安装/更新技能后,会将其安装到共享 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 依赖,可保留仅含注释的 `requirements.txt`(宿主会识别为 empty 并跳过安装)。
## 4. 开发一个新 skill 的标准步骤 ## 4. 开发一个新 skill 的标准步骤
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
### 第一步:复制模板并改目录名 ### 第一步:复制模板并改目录名
例如你要开发 `weibo-publisher` 例如你要开发 `your-skill-slug`(或 `disburse-payroll-icbc` 一类领域 skill
1. 复制 `skill-template` 1. 复制 `skill-template`
2. 新目录改成 `weibo-publisher` 2. 新目录改成与 `slug` 一致的名称(如 `your-skill-slug`
3. 初始化为独立 git 仓库 3. 初始化为独立 git 仓库
4. 关联它自己的远端仓库 4. 关联它自己的远端仓库
@@ -176,7 +210,7 @@ scripts/
1. `SKILL.md` 1. `SKILL.md`
2. `scripts/util/constants.py` 2. `scripts/util/constants.py`
3. `references/` 下的文案 3. `references/` 下的文案
4. `scripts/service/` 下的平台占位文件名 4. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`
最先要统一的是: 最先要统一的是:
@@ -193,18 +227,19 @@ scripts/
复制后,至少要全局检查并替换下面这些内容: 复制后,至少要全局检查并替换下面这些内容:
- `your-skill-slug` - `your-skill-slug`
- `your-platform-key` - `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位)
- `技能开发模板(复制后请修改)` - `技能开发模板(通用业务版)`
- `你的平台名` - `你的平台名`
- `platform_playwright.py`
- `openclaw.skill.your_skill_slug` - `openclaw.skill.your_skill_slug`
如果你是做发布型 skill通常还要替换 如果你 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`,通常还要替换:
- `publish` 命令中的中文提示 - `run` 命令中的中文提示与别名策略(见 `references/CLI.md`
- `references/CLI.md` 的命令示例 - `references/CLI.md` 的命令示例
- `references/README.md` 的用户话术 - `references/README.md` 的用户话术
- `references/SCHEMA.md` 的数据库文件名 - `references/SCHEMA.md` 的字段映射补充说明
如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。
## 6. `SKILL.md` 应该怎么写 ## 6. `SKILL.md` 应该怎么写
@@ -216,7 +251,7 @@ scripts/
- 技能描述 - 技能描述
- `slug` - `slug`
作用:技能的唯一英文标识,通常用于仓库名、发布包名、运行时目录名、平台主键匹配等 作用:技能的唯一英文标识,通常用于仓库名、发布包名、运行时目录名、平台主键匹配等
示例:`weibo-publisher``douyin-publisher``xiaohongshu-publisher` 示例:`your-skill-slug`、`disburse-payroll-icbc`、`tax-invoice-verify`
- `category` - `category`
作用:技能在平台中的分类,用于市场展示与归类,不是代码内部键 作用:技能在平台中的分类,用于市场展示与归类,不是代码内部键
示例:`通用`、`内容`、`办公协作`、`物流`、`抖音`、`小红书` 示例:`通用`、`内容`、`办公协作`、`物流`、`抖音`、`小红书`
@@ -224,12 +259,11 @@ scripts/
作用:声明发布后默认拥有可见权限的开发者用户 ID 列表 作用:声明发布后默认拥有可见权限的开发者用户 ID 列表
- `dependencies` - `dependencies`
作用:声明该技能依赖的兄弟技能或运行前置能力,便于平台或编排层识别依赖关系 作用:声明该技能依赖的兄弟技能或运行前置能力,便于平台或编排层识别依赖关系
示例: 示例(按业务填写;发布类可能会依赖账号 / 内容技能,其它领域可能完全不同)
```yaml ```yaml
dependencies: dependencies:
required: required:
- account-manager - some-sibling-skill
- content-manager
``` ```
- 何时使用本技能 - 何时使用本技能
- 对用户的引导话术 - 对用户的引导话术
@@ -288,6 +322,9 @@ metadata:
- `DEVELOPMENT.md` - `DEVELOPMENT.md`
写给技术人员的开发教程,也就是本文档 写给技术人员的开发教程,也就是本文档
- `TESTING.md`
写给技术人员的测试分层、隔离数据根与档位开关指南(详见 [`references/TESTING.md`](TESTING.md)
如果后面某个 skill 需要更细的说明,可以再加: 如果后面某个 skill 需要更细的说明,可以再加:
- `ERRORS.md` - `ERRORS.md`
@@ -319,8 +356,8 @@ metadata:
也就是说,`cli/app.py` 的职责是: 也就是说,`cli/app.py` 的职责是:
1. 打印帮助 1. 打印帮助
2. 定义 `publish / logs / log-get / health / version` 2. 定义 `run / logs / log-get / health / version`
3. 把参数转交给 `service.publish_service` 3. 把参数转交给 `service.task_service`
不要在 `cli/app.py` 里直接写: 不要在 `cli/app.py` 里直接写:
@@ -334,14 +371,14 @@ metadata:
通常可以这样拆: 通常可以这样拆:
- `publish_service.py` - `task_service.py`
放命令编排、参数兜底、结果分流 放命令编排、参数兜底、结果分流(例如 `cmd_run`
- `sibling_bridge.py` - `sibling_bridge.py`
放兄弟技能调用,例如调 `account-manager`、`content-manager` 通用兄弟技能子进程工具(`call_sibling_json`**具体调用哪个 slug 由业务在 `task_service.py` 决定**,不要在本文件硬编码发布类专用 helper
- `*_playwright.py` - `xxx_playwright.py`(按需新建)
浏览器后台自动化 浏览器后台自动化 **不在模板默认结构中**;若确有需要,按命名约定自建模块并在 `task_service.py` 引用
- `entitlement_service.py` - `entitlement_service.py`
放鉴权逻辑 放鉴权逻辑
@@ -352,7 +389,7 @@ metadata:
推荐流向是: 推荐流向是:
`cli.app` -> `service.publish_service` -> `service.sibling_bridge` / `service.xxx_playwright` -> `db` `cli.app` -> `service.task_service` -> `service.sibling_bridge` / (可选)`service.xxx_playwright` -> `db`
## 11. `db` 层怎么写 ## 11. `db` 层怎么写
@@ -361,8 +398,8 @@ metadata:
- `db/connection.py` - `db/connection.py`
只做连接和建表 只做连接和建表
- `db/publish_logs_repository.py` - `db/task_logs_repository.py`
只做增删查改 只做增删查改(模板默认表:`task_logs`
不要在 `db` 层里: 不要在 `db` 层里:
@@ -381,10 +418,7 @@ metadata:
来做调用。 来做调用。
常见调用对象是: 调用哪些兄弟技能由具体业务决定;请在 `task_service.py` 中使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)` 或 `get_sibling_main_path(skill_slug)`,不要在本模板仓库的 `sibling_bridge.py` 中堆积特定技能函数。
- `account-manager`
- `content-manager`
调用原则: 调用原则:
@@ -413,7 +447,7 @@ metadata:
- 日志或本地状态能落下来 - 日志或本地状态能落下来
- 错误返回值格式定好了 - 错误返回值格式定好了
如果你的 skill 恰好是 publisher 类,可以把上面的“核心 `service`”具体落成 `publish_service.py`,再逐步接 `sibling_bridge.py`、`*_playwright.py`。这只是示例,不代表模板只适合发布类技能 如果你的 skill 属于外联型 / RPA 型,可以把上面的“核心 `service`”具体落成 `task_service.py`,再按需接 `sibling_bridge.py`、(可选)`*_playwright.py`。这只是示例路径组合,不代表模板绑定某一业务领域
## 14. 本地开发的最小验证顺序 ## 14. 本地开发的最小验证顺序
@@ -442,7 +476,7 @@ python scripts/main.py <your-log-command>
python scripts/main.py <your-detail-command> <id> python scripts/main.py <your-detail-command> <id>
``` ```
如果你沿用了模板中的发布型骨架,那么这里可以具体对应成: 如果你沿用了模板中的通用任务日志骨架,那么这里可以具体对应成:
```bash ```bash
python scripts/main.py logs python scripts/main.py logs
@@ -457,8 +491,22 @@ python scripts/main.py log-get 1
python scripts/main.py <your-command> python scripts/main.py <your-command>
``` ```
## 14.5 测试驱动的开发顺序
新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍:
1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认全部默认测试套件通过。
- 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。
2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。
3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。
4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。
详见 `references/TESTING.md`。
## 15. 发布到正式环境验证 ## 15. 发布到正式环境验证
在执行 `release.ps1` 之前,必须先在本地确认 `python tests/run_tests.py -v` 通过。测试不通过不得发起正式发布。
当本地开发、自测和联调完成后,还需要把 skill 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。 当本地开发、自测和联调完成后,还需要把 skill 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。
### 第一步:在 skill 根目录执行 `release.ps1` ### 第一步:在 skill 根目录执行 `release.ps1`
@@ -574,12 +622,15 @@ 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` 下的平台文件名已改对 - [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
- [ ] 没有残留旧平台名 - [ ] 没有残留旧平台名
- [ ] `health` / `version` 可运行 - [ ] `health` / `version` 可运行
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去 - [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
- [ ] `release.ps1` 存在 - [ ] `release.ps1` 存在
- [ ] `.github/workflows/release_skill.yaml` 存在 - [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
## 17. 常见错误 ## 17. 常见错误
@@ -601,11 +652,11 @@ python scripts/main.py <your-command>
表现: 表现:
- 兄弟技能筛选账号失败 - 兄弟技能筛选账号失败
- 发布命令走错平台 - `run` 命令走错平台或 task_type
要检查: 要检查:
- `publish_service.py` - `task_service.py`
- `sibling_bridge.py` - `sibling_bridge.py`
- `references/CLI.md` - `references/CLI.md`
@@ -644,9 +695,10 @@ python scripts/main.py <your-command>
4. 改 `references/` 4. 改 `references/`
5. 改 `scripts/cli/app.py` 5. 改 `scripts/cli/app.py`
6. 改 `scripts/service/` 6. 改 `scripts/service/`
7. 跑 `health` / `version` 7. 跑 `python tests/run_tests.py -v`
8. 再做业务联调 8. 跑 `health` / `version`
9. 最后 release 9. 再做业务联调
10. 最后 release
## 19. 这份模板的底线要求 ## 19. 这份模板的底线要求

View File

@@ -1,5 +1,5 @@
--- ---
description: "这是规范化的新技能模板说明,不直接作为业务技能使用。复制后替换技能名、平台名、CLI 示例与 service 实现。" description: "规范化的新技能模板说明复制后替换名、CLI 示例与 task_service 等业务实现。"
--- ---
# 技能模板说明 # 技能模板说明
@@ -9,9 +9,10 @@ description: "这是规范化的新技能模板说明,不直接作为业务技
## 它提供什么 ## 它提供什么
- 标准目录结构 - 标准目录结构
- 最小 CLI 入口 - 最小 CLI 入口`run` / `logs` / `log-get` / `health` / `version`
- 发布型技能常见的日志表骨架 - 通用任务日志表骨架(`task_logs`
- `service` 层占位模块 - `service` 层占位模块`task_service.py`、通用 `sibling_bridge.py`
- 成熟的隔离测试体系IsolatedDataRoot、FakeAdapter、6 档测试 target、profile 策略)
- 与现有规范 skill 一致的发布脚本与 GitHub workflow - 与现有规范 skill 一致的发布脚本与 GitHub workflow
## 复制后你需要改什么 ## 复制后你需要改什么
@@ -20,7 +21,8 @@ description: "这是规范化的新技能模板说明,不直接作为业务技
- `SKILL.md``metadata.openclaw.developer_ids`(如需让非公开技能默认授权给开发者查看) - `SKILL.md``metadata.openclaw.developer_ids`(如需让非公开技能默认授权给开发者查看)
- `references/CLI.md` 里的命令示例 - `references/CLI.md` 里的命令示例
- `scripts/util/constants.py` 中的 slug / 版本 / logger 名 - `scripts/util/constants.py` 中的 slug / 版本 / logger 名
- `scripts/service/` 下的真实业务实现 - `scripts/service/task_service.py` 中的 `cmd_run` 真实业务逻辑
- 如有特定 task_type`references/SCHEMA.md` 中补充字段映射说明
## `developer_ids` 是做什么的 ## `developer_ids` 是做什么的
@@ -44,3 +46,19 @@ description: "这是规范化的新技能模板说明,不直接作为业务技
- 旧入口 `scripts/skill_main.py` - 旧入口 `scripts/skill_main.py`
新模板统一使用 `scripts/main.py` 作为入口。 新模板统一使用 `scripts/main.py` 作为入口。
## 参考文档索引
| 文档 | 内容 |
|------|------|
| `DEVELOPMENT.md` | 从复制模板到开发出新 skill 的完整教程 |
| `RUNTIME.md` | 运行时目录结构与 `CLAW_*` 环境变量约定 |
| `CLI.md` | CLI 命令与参数示例 |
| `SCHEMA.md` | task_logs 与字段映射说明 |
| `TESTING.md` | 测试 target 与隔离体系 |
| **`RPA.md`** | **三端 RPA 标准(浏览器/桌面/手机):选型、拟人操作、反反爬、人工兜底、存证** |
| **`ADAPTER.md`** | **真实/仿真 × API/RPA 四档适配器模式** |
| **`CONFIG.md`** | **`.env` 配置规范与首次自动落盘机制** |
| `REQUIREMENTS.md` | 依赖与运行要求 |
> 开发"操作浏览器/桌面/手机"或"对接 ERP/CRM/SaaS/银行"类 skill 前,**务必先读 `RPA.md` + `ADAPTER.md` + `CONFIG.md`** 这三份标准,避免重复踩坑。

View File

@@ -26,7 +26,7 @@
示例: 示例:
- 本文档用于明确 `weibo-publisher` 的研发需求,约束该 skill 的功能边界、输入输出和验收标准,作为开发、测试和上线验证的统一依据 - 本文档用于明确 `your-skill-slug`(示例:`disburse-payroll-icbc`的研发范围、交付标准、依赖关系和验收要求
## 2. 业务背景 ## 2. 业务背景
@@ -40,8 +40,8 @@
示例: 示例:
- 当前内容运营团队需要将内容库中的文章批量发布到微博平台,但人工登录后台、复制文章、填写标题和确认发布的流程效率较低,且不便于统一记录发布历史 - 当前财务团队需要将工资代发批次与银行接口对账,人工导出表格、核对差异耗时且易错
- 因此需要开发 `weibo-publisher`,用于把内容库文章按规范发布到微博平台,并记录发布结果,支持后续排查与复用 - 因此需要开发 `disburse-payroll-icbc`,用于自动拉取代发结果、写入任务日志 `task_logs` 并输出对账摘要,支持审计追踪
## 3. 开发目标 ## 3. 开发目标
@@ -57,10 +57,10 @@
示例: 示例:
- 完成 `weibo-publisher` 的标准 skill 结构建设 - 完成 `your-skill-slug` 的标准 skill 结构建设
- 提供 `health``version``publish``logs``log-get` 等命令 - 提供 `health``version``run``logs``log-get` 等命令
- 支持从兄弟技能读取账号与文章,并执行微博发布流程 - 支持对接银行 / 薪酬系统 API示例并记录任务执行结果
- 支持记录发布日志,并可在正式环境中安装验证 - 支持写入任务日志 `task_logs`,并可在正式环境中安装验证
## 4. 功能范围 ## 4. 功能范围
@@ -82,17 +82,16 @@
- 支持 `python scripts/main.py health` - 支持 `python scripts/main.py health`
- 支持 `python scripts/main.py version` - 支持 `python scripts/main.py version`
- 支持 `python scripts/main.py publish` - 支持 `python scripts/main.py run`
- 支持从 `account-manager` 获取可用账号 - 支持从 `some-sibling-skill` 读取批次元数据(如需要)
- 支持`content-manager` 获取待发布文章 - 支持执行核心业务编排HTTP / 批处理 / 可选 RPA
- 支持执行微博后台发布流程 - 支持写入任务日志(`task_logs`
- 支持写入发布日志
### 可后续扩展 ### 可后续扩展
- 支持定时发布 - 支持定时批跑
- 支持失败自动重试 - 支持失败自动重试
- 支持多账号轮询发布 - 支持多目标轮询
## 5. 非功能要求 ## 5. 非功能要求
@@ -112,7 +111,7 @@
- 项目结构必须对齐 `skill-template` - 项目结构必须对齐 `skill-template`
- CLI 入口统一使用 `scripts/main.py` - CLI 入口统一使用 `scripts/main.py`
- 关键执行结果应可通过 JSON 或固定文本结构返回 - 关键执行结果应可通过 JSON 或固定文本结构返回
- 发布日志必须可追踪 - 任务日志必须可追踪`task_logs`
- 不允许重新引入旧模板中的 `docs/``optional/``scripts/skill_main.py` - 不允许重新引入旧模板中的 `docs/``optional/``scripts/skill_main.py`
## 6. 输入输出要求 ## 6. 输入输出要求
@@ -138,16 +137,15 @@
### 输入 ### 输入
- `account_id`:可选,指定发布账号 - `target_id`:可选,指定任务目标(账号 / 客户 / 平台)
- `article_id`:可选,指定发布文章 - `input_id`:可选,指定输入批次或记录
- `account-manager` 返回的账号信息 - 兄弟技能返回的结构化数据(如有)
- `content-manager` 返回的文章信息
### 输出 ### 输出
- 发布成功时返回成功结果 - 成功结果stdout / JSON
- 发布失败时返回 `ERROR:` `FAIL:` 前缀的错误信息 - 错误输出(`ERROR:` / `FAIL:`
- 在本地数据库写入 `publish_logs` - 在本地数据库写入 `task_logs`
- 在日志目录中写入执行日志 - 在日志目录中写入执行日志
## 7. 依赖的兄弟技能或外部系统 ## 7. 依赖的兄弟技能或外部系统
@@ -166,17 +164,28 @@
- `【平台名】`:作用是 `【用途】` - `【平台名】`:作用是 `【用途】`
- `【浏览器 / API / 第三方服务】`:作用是 `【用途】` - `【浏览器 / API / 第三方服务】`:作用是 `【用途】`
### Python 包依赖requirements.txt
- 本技能若需额外 Python 三方包,写入根目录 `requirements.txt`
- 匠厂宿主安装/更新技能后,会安装到 `{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 由宿主自动安装。
示例: 示例:
### 兄弟技能依赖 ### 兄弟技能依赖
- `account-manager`:提供账号信息 - `some-sibling-skill`:提供主数据或凭证索引(按业务填写)
- `content-manager`:提供文章数据
### 外部系统依赖 ### 外部系统依赖
- 微博后台:目标发布平台 - 银行 / 政务 HTTP API示例核心外部接口
- Chromium / Chrome / Edge用于浏览器自动化 - Chromium / Chrome / Edge(可选):用于浏览器自动化
- Gitea用于代码托管和发布工作流 - Gitea用于代码托管和发布工作流
- 匠厂客户端:用于正式环境安装与验证 - 匠厂客户端:用于正式环境安装与验证
@@ -193,8 +202,8 @@
示例: 示例:
- 本次不实现多平台统一发布 - 本次不实现多租户隔离之外的附加报表
- 本次不实现定时任务调度 - 本次不实现实时流式处理
- 本次不实现自动重试机制 - 本次不实现自动重试机制
- 本次不兼容旧模板中的历史目录结构 - 本次不兼容旧模板中的历史目录结构
@@ -214,12 +223,18 @@
示例: 示例:
- `health``version` 命令执行正常 - `health``version` 命令执行正常
- `publish` 主流程可运行 - `run` 主流程可运行
- 发布后 Gitea 工作流成功 - 发布后 Gitea 工作流成功
- 匠厂技能市场能看到最新版本 - 匠厂技能市场能看到最新版本
- skill 可以正常安装 - skill 可以正常安装
- 安装后可在“新建任务”中调用 - 安装后可在“新建任务”中调用
**测试与日志相关验收(所有新 skill 必须满足)**
- `python tests/run_tests.py -v` 必跑测试全部通过
- 任务日志task_logs写入和查询符合预期
- 真实联调(如有)已在 tests/integration/ 中验证,且默认套件不包含真实外联
## 10. 开发注意事项 ## 10. 开发注意事项
说明研发过程中必须特别注意的事项。 说明研发过程中必须特别注意的事项。
@@ -258,7 +273,7 @@
1. 先写 `references/REQUIREMENTS.md` 1. 先写 `references/REQUIREMENTS.md`
2. 再按 `references/DEVELOPMENT.md` 进入开发 2. 再按 `references/DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `CLI.md``SCHEMA.md``RUNTIME.md` 3. 开发过程中补充 `CLI.md``SCHEMA.md``RUNTIME.md``TESTING.md`
4. 发布前回到 `REQUIREMENTS.md` 对照验收标准逐项检查 4. 发布前回到 `REQUIREMENTS.md` 对照验收标准逐项检查
## 最小模板示例 ## 最小模板示例
@@ -270,24 +285,24 @@
## 1. 文档目标 ## 1. 文档目标
本文档用于明确 `weibo-publisher` 的研发需求、范围和验收标准,作为开发与测试的统一依据。 本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
## 2. 业务背景 ## 2. 业务背景
运营团队需要将内容库中的文章发布到微博平台,现有人工操作效率低,且缺少统一的发布记录 采购审计需要将政府采购公告结构化归档;人工复制粘贴效率低,需要可追溯的任务日志
## 3. 开发目标 ## 3. 开发目标
- 完成 `weibo-publisher` 的标准 skill 结构 - 完成 `your-skill-slug` 的标准 skill 结构
- 支持文章发布主流程 - 支持核心业务批处理 / HTTP 对接主流程
- 支持发布日志记录 - 支持任务日志记录task_logs
## 4. 功能范围 ## 4. 功能范围
- 支持 `health` - 支持 `health`
- 支持 `version` - 支持 `version`
- 支持 `publish` - 支持 `run`
- 支持查询发布日志 - 支持查询任务日志logs / log-get
## 5. 非功能要求 ## 5. 非功能要求
@@ -299,29 +314,30 @@
### 输入 ### 输入
- `account_id` - `target_id`
- `article_id` - `input_id`
### 输出 ### 输出
- 成功结果 - 成功结果
- 错误信息 - 错误信息
- 发布日志记录 - task_logs 记录
## 7. 依赖的兄弟技能或外部系统 ## 7. 依赖的兄弟技能或外部系统
- `account-manager` - (按需填写兄弟 skill
- `content-manager` - 政务公告站点 HTTP API示例
- 微博后台
## 8. 不在本次范围内的内容 ## 8. 不在本次范围内的内容
- 不实现定时发布 - 不实现浏览器可视化回放(如需另行立项)
- 不实现自动重试 - 不实现自动重试
## 9. 验收标准 ## 9. 验收标准
- 命令可运行 - 命令可运行
- `python tests/run_tests.py -v` 全部通过
- task_logs 写入符合 SCHEMA 约定
- 能完成正式环境安装验证 - 能完成正式环境安装验证
- 能在新建任务中使用 - 能在新建任务中使用

175
references/RPA.md Normal file
View File

@@ -0,0 +1,175 @@
# 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``disburse-payroll-icbc`)。
| 项 | 标准 |
|----|------|
| 驱动 | PlaywrightPython |
| 浏览器 | 系统已装 Chrome/Edge`channel="chrome"`**不用内置 Chromium无需 `playwright install`** |
| 登录态 | `launch_persistent_context` + 用户 Profile 目录(由 account-manager 下发) |
| 模式 | 默认有头;`OPENCLAW_BROWSER_HEADLESS=1` 转无头 |
| 反反爬 | stealth 指纹注入 + 拟人输入/鼠标/延迟 + 滑块停下等人工(见下) |
### 1.1 防反爬三件套(必做)
1. **指纹淡化stealth**:启动时 `add_init_script` 注入,抹平自动化特征——
`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages``hardwareConcurrency``deviceMemory`、WebGL vendor。
启动参数 `--disable-blink-features=AutomationControlled` + `ignore_default_args=["--enable-automation"]`
开关:`OPENCLAW_PLAYWRIGHT_STEALTH`(默认开)。
2. **拟人操作**
- 输入:逐字符 `keyboard.type(delay=90~240ms)`**先真实点击聚焦再输入,绝不 `el.value=`**。
- 鼠标:贝塞尔曲线轨迹移动 + 微抖动;进场随机晃动 2~4 次。
- 导航:用真实点击触发,**不要 `window.location.href=` / `el.click()` 这类 JS 跳转/点击**。
- 翻页:真实点击翻页箭头,注意排除禁用态选择器。
- 延迟:每步之间 `random_delay`
3. **风控页 = 停下等人工**:检测到滑块/拦截URL 含 `punish/x5sec/baxia/tmd`,或 DOM 命中 `#nocaptcha/.nc-container/punish-component` 等)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等人工通过或超时,**不自动操作滑块**。
> 参考实现:`1688-scrape-contacts/scripts/service/anti_detect.py`、`util/playwright_stealth.py`、`service/login_service.py`(验证码等待)。已提升为共享库 `jiangchang_skill_core.rpa`(见 platform-kit
### 1.2 安装
```bash
pip install playwright # 仅装包;用系统 Chrome无需 playwright install chromium
```
### 引用方式
共享实现位于 `jiangchang-platform-kit``jiangchang_skill_core.rpa`;模板通过 vendor 拷贝引用:
```python
# scripts/jiangchang_skill_core/rpa/ — vendored from platform-kit
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
```
完整示例见 `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. 存证规范
- **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **运行录屏成片**RPA 型技能默认 `OPENCLAW_RECORD_VIDEO=1`,表示 **ffmpeg 录制运行过程** + 字幕 + 背景音乐 + 最终 MP4 **一体开启**。由 `RpaVideoSession` 统一触发,不在 CLI/E2E 单独实现。
- **Playwright 不负责录屏**,仅浏览器自动化。
- **ffmpeg 是唯一录屏器**Windows`gdigrab` + `desktop`)。
- **最终视频**`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**`rpa-artifacts/{batch_id}/capture.mp4``subtitles/``logs/` 等,**不放最终 MP4**。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`
---
## 相关文档
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md``.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

View File

@@ -33,6 +33,17 @@
{...}/{skill_slug}.db {...}/{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 包装 - Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装

View File

@@ -4,20 +4,35 @@
`{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db` `{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db`
## 发布型技能推荐日志表 ## 通用任务日志表task_logs
| 字段 | 说明 | 模板默认建表:
|------|------|
| `id` | 自增主键 | | 字段 | 类型 | 说明 |
| `account_id` | 账号 id | |------|------|------|
| `article_id` | 文章 id | | `id` | INTEGER PK | 自增主键 |
| `article_title` | 标题快照 | | `task_type` | TEXT NOT NULL | 任务类型disburse / reconcile / verify ... |
| `status` | `published` / `failed` / `require_login` | | `target_id` | TEXT | 任务目标(账号、平台、客户等的 ID |
| `error_msg` | 错误说明 | | `input_id` | TEXT | 输入对象 ID |
| `created_at` | Unix 时间戳 | | `input_title` | TEXT | 输入对象标题快照 |
| `updated_at` | Unix 时间戳 | | `status` | TEXT NOT NULL | success / failed / partial / require_login 等 |
| `error_msg` | TEXT | 错误说明 |
| `result_summary` | TEXT | 结果摘要(建议存 JSON 字符串) |
| `created_at` | INTEGER | Unix 时间戳 |
| `updated_at` | INTEGER | Unix 时间戳 |
## 不同业务的字段映射建议
| 业务场景 | task_type | target_id 含义 | input_id 含义 |
|---|---|---|---|
| 发布类 | publish | 账号 ID | 文章 ID |
| 工资代发 | disburse | 付款账户 | 工资表批次 ID |
| 对账 | reconcile | 银行 / 平台 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
| 报关 | declare | 港口 / 海关 | 报关批次 ID |
## 模板原则 ## 模板原则
- 模板不做历史迁移兼容设计 - 模板不做历史迁移兼容设计
- 新 skill 直接从当前 schema 起步 - 新 skill 直接从当前 schema 起步
- 业务有特殊字段时,建议放 `result_summary`JSON 字符串),不要乱加列

185
references/TESTING.md Normal file
View File

@@ -0,0 +1,185 @@
# 测试开发指南
面向复制 `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)(落地细则)。
默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**`*integration*``*.sample``desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。
---
## 1. 测试体系总览
本模板把测试分成四层漏斗:**默认必跑unittest + `run_tests.py`**、从 `tests/samples/` **按需复制的 service / golden**、放在 `tests/integration/` **默认不落盘的仿真或真实联调范式(多数仍是 `.sample`**、以及 **desktop E2Epytest + 宿主 SDK**
[`tests/run_tests.py`](../tests/run_tests.py) 只做三件事:把 `scripts/``tests/` 放进 `sys.path`、做 Windows UTF-8 包装、收集 **`tests/` 根目录**下的 `test_*.py`。它不递归子目录——这正是刻意的安全边界:**不想让 AI 或拷贝粘贴 accidentally 把 integration 拉进默认套件**。
当你在设计一个新 skill 的测试策略时,请先问自己:**这段代码在没有外部凭证与浏览器的前提下是否有意义?** 若有,留在默认套件;若无,放进 integration / `.sample`,并要求明确的 `OPENCLAW_TEST_TARGET` 组合开关。
更深表格化的目录映射、`FakeAdapter` 与 profile 的耦合细节见 [`tests/README.md`](../tests/README.md) 开头章节『我该把测试写在哪里』。
---
## 2. 默认必跑测试要做什么
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health` / `version` / `logs` / `log-get` 冒烟;
- **真实 subprocess**[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths`**`CLAW_*` / `JIANGCHANG_*` 并发兜底;
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- **adapter profile**[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration并由档位变量放行
套件能力与表格参见 [`tests/README.md`](../tests/README.md) 「1.2 默认套件覆盖」。
---
## 3. 数据隔离IsolatedDataRoot
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,**镜像写入四套变量**`CLAW_DATA_ROOT``JIANGCHANG_DATA_ROOT`(同一 tempfile、以及用户镜像 ID`CLAW_USER_ID``JIANGCHANG_USER_ID``_test`
结束时:**恢复原 environ**,删除目录。
这样可以断言:**SQLite DB / spill files / caches** 都在 sandbox不会在开发者桌面遗留 `{REAL_ROOT}`
示例:
```python
from _support import IsolatedDataRoot
def test_whatever():
with IsolatedDataRoot():
from db.connection import init_db
init_db()
# …断言读写均在隔离路径…
```
**不要把真实凭证路径硬编码进默认测试**:隔离不等于你有权触碰真实目录。
---
## 4. 测试目标档位OPENCLAW_TEST_TARGET
模板采用统一闸门:**你想跑到哪一层外部世界,就用变量明说**。合法档位(非法值会让 helper 抛错)如下——**直接摘录自 [`tests/README.md`](../tests/README.md) §5.1**
| 取值 | 含义 |
|------|------|
| `unit` | **默认**:单元 / 内存 / mock不跑仿真与真实外联 |
| `mock` | 与 `unit` 类似的安全档位(显式语义) |
| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** |
| `real_api` | 真实 API另需 `ALLOW_REAL_API=1` |
| `real_rpa` | 真实 RPA另需 `ALLOW_REAL_RPA=1` |
未设置环境变量 ⇒ 等价 `unit`
授权开关(显式 `1`)语义 **`ALLOW_REAL_API` / `ALLOW_REAL_RPA` / `ALLOW_WRITE_ACTIONS`**——摘录 [`tests/README.md`](../tests/README.md) §5.2
| 变量 | 作用 |
|------|------|
| `ALLOW_REAL_API=1` | 允许 `real_api` profile 访问真实 HTTP 通道 |
| `ALLOW_REAL_RPA=1` | 允许 `real_rpa` profile 驱动真实浏览器/RPA |
| `ALLOW_WRITE_ACTIONS=1` | 在 `real_*` 下允许**写**操作(提交表单、下单等) |
默认策略摘要见 §5.3**不要在 unittest 必跑路径误把闸门打开**。
兼容别名:`OPENCLOW_TEST_TARGET`(历史拼写)。
---
## 5. FakeAdapter怎么模拟外部系统
[`tests/adapter_test_utils.py`](../tests/adapter_test_utils.py) 暴露 `FakeAdapter`,典型四种 **mode**
| mode | 用途 |
|------|------|
| `success` | 构造干净的成功响应路径 |
| `timeout` | 模拟悬挂 / 慢链路 |
| `invalid_response` | 畸形负载 / schema drift |
| `unauthorized` | token / license / cookie 失效语义 |
**何时用它**service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter而不是直接把 CLI when-json 断言堆上天。
把它看成:**你把不确定性收敛到可控的测试矩阵里**,而不是在生产日志里才第一次看到错位字段。
---
## 6. 怎么从 .sample 启用一个测试
步骤模板:
1. 找到范式文件(例如 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))。
2. **复制**到 `tests/` 根:`tests/test_service_contract.py`(去掉 `.sample`)。
3. 打开副本:**替换占位函数名 /技能特有枚举 / adapter profile**,删掉与本技能无关的示例断言。
4. 本地执行:`python tests/run_tests.py -v [可选筛选关键词]`
5. **不要把 integration `.sample` 批量改名混进根目录**——除非你已经读过 [`tests/integration/README.md`](../tests/integration/README.md) 的风险清单。
Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../tests/samples/test_golden_cases.py.sample))。
---
## 7. 真实联调测试的安全约束
任何 touching **真实租户数据** 的路径:
1. **禁止**硬编码 token / cookie / 内部域名落入仓库;
2. **禁止**默认套件隐式导入 integration
3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。
范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。
记住:**测试代码也是一种部署面**,别把 staging 凭证写死进仓库。
---
## 8. 新 skill 的最小测试清单
以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选):
- [ ] `python tests/run_tests.py -v` 能通过。
- [ ] `python scripts/main.py health` 能通过。
- [ ] `python scripts/main.py version` 输出 JSON`skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。
- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。
- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。
- [ ] 至少有 1 个成功路径测试。
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
---
## 9. AI 编程工具使用测试时的红线
改编自 [`tests/README.md`](../tests/README.md) 「AI 编程工具注意事项」,压缩成 skill 开发者视角:
| 红线 | 解释 |
|------|------|
| 不改业务凑测试 | 除非需求变更已确认,否则**不要为了让 CI 变绿而砍掉业务分支** |
| **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` |
| `.sample` 尊重 | 集成范式改名前先读完 README别让 `.sample` 悄悄变成根测试 |
| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref |
| mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 |
| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 |
| **integration = 显式开关** | `OPENCLAW_TEST_TARGET` + `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` |
---
## 10. 测试和发布的关系
**在运行 [`release.ps1`](../release.ps1) 之前,`python tests/run_tests.py -v` 必须绿色**。失败的默认套件意味着:
- 打包路径可能根本不可运行;
- CI 加密前的静态假设可能在宿主崩溃;
- metadata slug 漂移将被市场拒绝。
把『本地 unittest 绿』视作 tag 的前置条件,而不是『有空再跑』。发布流水线成功后仍要做安装验证——那是另一个维度;**测试是第一个维度**。
---
## 延伸阅读
- [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则
- [`references/DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序
- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
# Skill-specific Python dependencies.
# Keep this file minimal.
# Platform-provided shared dependencies are installed by Jiangchang runtime.
# Add only dependencies required by this skill.
#
# Browser RPA: uses system Chrome/Edge; no `playwright install chromium` needed.
playwright>=1.42.0,<2

View File

@@ -6,11 +6,11 @@ import argparse
import sys import sys
from typing import List, Optional from typing import List, Optional
from service.publish_service import ( from service.task_service import (
cmd_health, cmd_health,
cmd_log_get, cmd_log_get,
cmd_logs, cmd_logs,
cmd_publish, cmd_run,
cmd_version, cmd_version,
) )
from util.argparse_zh import ZhArgumentParser from util.argparse_zh import ZhArgumentParser
@@ -25,34 +25,34 @@ def _cli_str_or_none(raw: Optional[str]) -> Optional[str]:
return v or None return v or None
def _handle_publish(args: argparse.Namespace) -> int: def _handle_run(args: argparse.Namespace) -> int:
tail = [str(x).strip() for x in (args.publish_tail or []) if str(x).strip()] tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
if len(tail) > 2: if len(tail) > 2:
print("❌ 参数过多。") print("❌ 参数过多。")
print("用法python main.py publish [账号id [文章id]] | publish [-a 账号id] [-i 文章id]") print("用法python main.py run [target [input_id]] | run [-t target] [-i input_id]")
return 1 return 1
t_acc: Optional[str] = None t_target: Optional[str] = None
t_art: Optional[str] = None t_input: Optional[str] = None
if len(tail) == 2: if len(tail) == 2:
t_acc, t_art = tail[0], tail[1] t_target, t_input = tail[0], tail[1]
elif len(tail) == 1: elif len(tail) == 1:
if tail[0].isdigit(): if tail[0].isdigit():
t_art = tail[0] t_input = tail[0]
else: else:
t_acc = tail[0] t_target = tail[0]
pick_a = _cli_str_or_none(getattr(args, "account_id", None)) pick_t = _cli_str_or_none(getattr(args, "target", None))
pick_i = _cli_str_or_none(getattr(args, "article_id", None)) pick_i = _cli_str_or_none(getattr(args, "input_id", None))
acc = pick_a or t_acc target = pick_t or t_target
art = pick_i or t_art input_id = pick_i or t_input
return cmd_publish(account_id=acc, article_id=art) return cmd_run(target=target, input_id=input_id)
def _print_full_usage() -> None: def _print_full_usage() -> None:
print("模板技能main.py可用命令") print("通用业务技能模板main.py可用命令")
print(" python main.py publish [账号id [文章id]] [-a 账号] [-i 文章id]") print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
print(" python main.py logs [--limit N] [--status s] [--account-id a]") 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 version") print(" python main.py version")
@@ -61,24 +61,27 @@ def _print_full_usage() -> None:
def build_parser() -> ZhArgumentParser: def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser( p = ZhArgumentParser(
prog="main.py", prog="main.py",
description="模板技能:发布命令骨架、日志查询、健康检查、版本输出。", description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出。",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
sp = sub.add_parser("publish", help="发布型技能命令骨架") sp = sub.add_parser("run", help="任务执行命令骨架")
sp.add_argument("--account-id", "-a", default=None, metavar="账号id") sp.add_argument("--target", "-t", default=None, metavar="目标")
sp.add_argument("--article-id", "-i", default=None, metavar="文章id") sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id")
sp.add_argument("publish_tail", nargs="*", metavar="位置参数") sp.add_argument("run_tail", nargs="*", metavar="位置参数")
sp.set_defaults(handler=_handle_publish) sp.set_defaults(handler=_handle_run)
sp = sub.add_parser("logs", help="查看发布记录") sp = sub.add_parser("logs", help="查看任务日志")
sp.add_argument("--limit", type=int, default=10) sp.add_argument("--limit", type=int, default=10)
sp.add_argument("--status", default=None) sp.add_argument("--status", default=None)
sp.add_argument("--account-id", default=None) sp.add_argument("--task-type", default=None, dest="task_type")
sp.set_defaults(handler=lambda a: cmd_logs(limit=a.limit, status=a.status, account_id=a.account_id)) sp.add_argument("--target-id", default=None, dest="target_id")
sp.set_defaults(handler=lambda a: cmd_logs(
limit=a.limit, status=a.status, task_type=a.task_type, target_id=a.target_id
))
sp = sub.add_parser("log-get", help="按 log_id 查看单条发布记录(JSON)") sp = sub.add_parser("log-get", help="按 log_id 查看单条任务日志(JSON)")
sp.add_argument("log_id") sp.add_argument("log_id")
sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id)) sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id))
@@ -97,8 +100,8 @@ def main(argv: Optional[List[str]] = None) -> int:
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 {"publish", "logs", "log-get", "health", "version", "-h", "--help"}: if len(argv) == 2 and argv[0] not in {"run", "logs", "log-get", "health", "version", "-h", "--help"}:
return cmd_publish(account_id=argv[0], article_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)
return int(args.handler(args)) return int(args.handler(args))

View File

@@ -1,4 +1,4 @@
"""SQLite 连接与日志表迁移模板。""" """SQLite 连接与任务日志表迁移模板。"""
from __future__ import annotations from __future__ import annotations
@@ -17,13 +17,15 @@ def init_db() -> None:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS publish_logs ( CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL, task_type TEXT NOT NULL,
article_id INTEGER NOT NULL, target_id TEXT,
article_title TEXT, input_id TEXT,
input_title TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
error_msg TEXT, error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
) )

View File

@@ -1,76 +0,0 @@
"""publish_logs 表读写模板。"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
from db.connection import get_conn, init_db
from util.timeutil import now_unix
def save_publish_log(
account_id: str,
article_id: int,
article_title: str,
status: str,
error_msg: Optional[str] = None,
) -> int:
init_db()
now = now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO publish_logs (account_id, article_id, article_title, status, error_msg, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(account_id, int(article_id), article_title or "", status, error_msg, now, now),
)
new_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
return new_id
def list_publish_logs(
limit: int,
status: Optional[str] = None,
account_id: Optional[str] = None,
) -> List[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at "
"FROM publish_logs WHERE 1=1 "
)
params: List[Any] = []
if status:
sql += "AND status = ? "
params.append(status)
if account_id:
sql += "AND account_id = ? "
params.append(account_id)
sql += "ORDER BY created_at DESC, id DESC LIMIT ?"
params.append(int(limit))
cur.execute(sql, tuple(params))
return list(cur.fetchall())
finally:
conn.close()
def get_publish_log_by_id(log_id: int) -> Optional[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at FROM publish_logs WHERE id = ?",
(int(log_id),),
)
return cur.fetchone()
finally:
conn.close()

View File

@@ -0,0 +1,88 @@
"""task_logs 表读写模板。
通用业务日志仓储:记录每次任务执行的 task_type / target / input / 状态 / 结果摘要。
不假设任何特定业务(发布、对账、审核等都可复用)。
"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
from db.connection import get_conn, init_db
from util.timeutil import now_unix
def save_task_log(
task_type: str,
target_id: Optional[str] = None,
input_id: Optional[str] = None,
input_title: Optional[str] = None,
status: str = "success",
error_msg: Optional[str] = None,
result_summary: Optional[str] = None,
) -> int:
init_db()
now = now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO task_logs
(task_type, target_id, input_id, input_title, status, error_msg, result_summary, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary, now, now),
)
new_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
return new_id
def list_task_logs(
limit: int,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> List[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE 1=1 "
)
params: List[Any] = []
if status:
sql += "AND status = ? "
params.append(status)
if task_type:
sql += "AND task_type = ? "
params.append(task_type)
if target_id:
sql += "AND target_id = ? "
params.append(target_id)
sql += "ORDER BY created_at DESC, id DESC LIMIT ?"
params.append(int(limit))
cur.execute(sql, tuple(params))
return list(cur.fetchall())
finally:
conn.close()
def get_task_log_by_id(log_id: int) -> Optional[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE id = ?",
(int(log_id),),
)
return cur.fetchone()
finally:
conn.close()

View File

@@ -1 +1,16 @@
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync. # Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging + config + rpa in sync.
from .config import ensure_env_file, get, get_bool, get_float, get_int
try:
from . import rpa
except ImportError:
rpa = None # type: ignore[assignment,misc]
__all__ = [
"ensure_env_file",
"get",
"get_bool",
"get_float",
"get_int",
"rpa",
]

View File

@@ -0,0 +1,126 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""三级优先级配置读取 + 首次 .env 落盘(进程 env > 数据目录 .env > .env.example"""
from __future__ import annotations
import os
import shutil
from typing import Any
from .runtime_env import get_data_root, get_user_id
_skill_slug: str | None = None
_example_path: str | None = None
_env_file_path: str | None = None
_user_env_cache: dict[str, str] | None = None
_example_defaults_cache: dict[str, str] | None = None
def _parse_env_file(path: str) -> dict[str, str]:
"""标准库手写 .env 解析(不引 python-dotenv"""
result: dict[str, str] = {}
if not path or not os.path.isfile(path):
return result
with open(path, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if not key:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
elif " #" in value:
value = value.split(" #", 1)[0].strip()
result[key] = value
return result
def _get_user_env() -> dict[str, str]:
global _user_env_cache
if _user_env_cache is not None:
return _user_env_cache
if _env_file_path and os.path.isfile(_env_file_path):
_user_env_cache = _parse_env_file(_env_file_path)
else:
_user_env_cache = {}
return _user_env_cache
def _get_example_defaults() -> dict[str, str]:
global _example_defaults_cache
if _example_defaults_cache is not None:
return _example_defaults_cache
if _example_path and os.path.isfile(_example_path):
_example_defaults_cache = _parse_env_file(_example_path)
else:
_example_defaults_cache = {}
return _example_defaults_cache
def reset_cache() -> None:
"""测试用:清空解析缓存。"""
global _user_env_cache, _example_defaults_cache
_user_env_cache = None
_example_defaults_cache = None
def ensure_env_file(skill_slug: str, example_path: str) -> str:
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env已存在不覆盖返回路径。"""
global _skill_slug, _example_path, _env_file_path
_skill_slug = skill_slug
_example_path = os.path.abspath(example_path)
dest_dir = os.path.join(get_data_root(), get_user_id(), skill_slug)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, ".env")
_env_file_path = dest
if not os.path.isfile(dest) and os.path.isfile(_example_path):
shutil.copy2(_example_path, dest)
reset_cache()
return dest
def get(key: str, default: Any = None) -> str | None:
"""进程环境变量 > 数据目录 .env > .env.example 默认值。"""
env_val = os.environ.get(key)
if env_val is not None and env_val != "":
return env_val
user_val = _get_user_env().get(key)
if user_val is not None and user_val != "":
return user_val
example_val = _get_example_defaults().get(key)
if example_val is not None and example_val != "":
return example_val
return default
def get_bool(key: str, default: bool = False) -> bool:
val = get(key)
if val is None:
return default
return str(val).strip().lower() in ("1", "true", "yes", "on")
def get_float(key: str, default: float) -> float:
val = get(key)
if val is None:
return default
try:
return float(val)
except (TypeError, ValueError):
return default
def get_int(key: str, default: int) -> int:
val = get(key)
if val is None:
return default
try:
return int(val)
except (TypeError, ValueError):
return default

View File

@@ -0,0 +1,66 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""浏览器 RPA 共享库stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
from __future__ import annotations
from . import anti_detect, artifacts, errors, human_login, stealth
try:
from .browser import launch_persistent_browser
except ImportError:
launch_persistent_browser = None # type: ignore[misc, assignment]
try:
from .video_session import RpaVideoSession
except ImportError:
RpaVideoSession = None # type: ignore[misc, assignment]
__all__ = [
"anti_detect",
"artifacts",
"errors",
"human_login",
"stealth",
"launch_persistent_browser",
"RpaVideoSession",
# re-export common anti_detect helpers
"random_delay",
"human_delay_short",
"human_delay_page",
"human_delay_batch",
"human_mouse_move",
"human_mouse_wiggle",
"human_click",
"human_type",
"human_scroll",
# human_login
"wait_for_captcha_pass",
"wait_for_login",
"DEFAULT_CAPTCHA_MARKERS",
# artifacts
"artifact_path",
"capture_failure",
# stealth
"STEALTH_INIT_SCRIPT",
"stealth_enabled",
"persistent_context_launch_parts",
]
from .anti_detect import (
human_click,
human_delay_batch,
human_delay_page,
human_delay_short,
human_mouse_move,
human_mouse_wiggle,
human_scroll,
human_type,
random_delay,
)
from .artifacts import artifact_path, capture_failure
from .human_login import DEFAULT_CAPTCHA_MARKERS, wait_for_captcha_pass, wait_for_login
from .stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)

View File

@@ -0,0 +1,216 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""防反爬:随机延迟、人工鼠标轨迹模拟、拟人滚动。
所有延迟使用 random.uniform() + asyncio.sleep()。
鼠标移动使用三次贝塞尔曲线 + 随机抖动,避免机械直线轨迹。
"""
from __future__ import annotations
import asyncio
import math
import random
from typing import Optional
# ── 随机延迟 ────────────────────────────────────────────────
async def random_delay(min_s: float, max_s: float) -> float:
"""随机延迟 min_s ~ max_s 秒,返回实际延迟秒数。"""
delay = random.uniform(min_s, max_s)
await asyncio.sleep(delay)
return delay
async def human_delay_short() -> float:
"""页面内操作间短延迟 0.5~2 秒(点击/滚动)。"""
return await random_delay(0.5, 2.0)
async def human_delay_page(min_s: float = 2, max_s: float = 8) -> float:
"""页面间/店铺间切换延迟(可配置范围)。"""
return await random_delay(min_s, max_s)
async def human_delay_batch(min_s: float = 5, max_s: float = 15) -> float:
"""批次间/翻页间长延迟。"""
return await random_delay(min_s, max_s)
# ── 鼠标轨迹 ────────────────────────────────────────────────
async def human_mouse_move(
page,
target_x: float,
target_y: float,
steps: Optional[int] = None,
) -> None:
"""模拟人工鼠标移动到目标坐标。
使用三次贝塞尔曲线生成 S/C 形路径,中间控制点随机偏移,
每步添加微小随机抖动,避免完美直线移动。
Args:
page: Playwright Page 对象。
target_x: 目标 X 坐标。
target_y: 目标 Y 坐标。
steps: 移动步数None 时按距离自动计算)。
"""
if steps is None:
dist = math.hypot(target_x, target_y)
steps = max(8, int(dist / 15))
# 随机起始点(模拟鼠标从页面其他位置移动过来)
start_x = target_x + random.uniform(-300, 300)
start_y = target_y + random.uniform(-200, 200)
# 两个贝塞尔控制点(制造弯曲路径)
dx = target_x - start_x
dy = target_y - start_y
cp1_x = start_x + dx * random.uniform(0.2, 0.5) + random.uniform(-120, 120)
cp1_y = start_y + dy * random.uniform(0.1, 0.4) + random.uniform(-100, 100)
cp2_x = start_x + dx * random.uniform(0.5, 0.8) + random.uniform(-100, 100)
cp2_y = start_y + dy * random.uniform(0.4, 0.7) + random.uniform(-80, 80)
for i in range(steps + 1):
t = i / steps
# 三次贝塞尔: B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
u = 1 - t
x = (
(u ** 3) * start_x
+ 3 * (u ** 2) * t * cp1_x
+ 3 * u * (t ** 2) * cp2_x
+ (t ** 3) * target_x
)
y = (
(u ** 3) * start_y
+ 3 * (u ** 2) * t * cp1_y
+ 3 * u * (t ** 2) * cp2_y
+ (t ** 3) * target_y
)
# 微小随机抖动
x += random.uniform(-1.5, 1.5)
y += random.uniform(-1.5, 1.5)
await page.mouse.move(x, y)
await asyncio.sleep(random.uniform(0.003, 0.015))
# 最终精确到位
await page.mouse.move(target_x, target_y)
async def human_click(
page,
selector: Optional[str] = None,
*,
x: Optional[float] = None,
y: Optional[float] = None,
) -> None:
"""拟人点击:贝塞尔轨迹移动 → 微延迟 → 点击。
支持两种定位方式:
- selector: CSS 选择器,自动取元素中心附近随机偏移
- x, y: 直接指定坐标(自动加微小随机偏移)
Args:
page: Playwright Page 对象。
selector: CSS 选择器(与 x/y 二选一)。
x: 目标 X 坐标。
y: 目标 Y 坐标。
"""
if selector:
locator = page.locator(selector).first
box = await locator.bounding_box()
if box is None:
raise ValueError(f"无法获取元素边界: {selector}")
target_x = box["x"] + box["width"] * random.uniform(0.3, 0.7)
target_y = box["y"] + box["height"] * random.uniform(0.3, 0.7)
elif x is not None and y is not None:
target_x = x + random.uniform(-3, 3)
target_y = y + random.uniform(-3, 3)
else:
raise ValueError("必须提供 selector 或 (x, y) 坐标")
await human_mouse_move(page, target_x, target_y)
await human_delay_short()
await page.mouse.click(target_x, target_y)
# ── 滚动模拟 ────────────────────────────────────────────────
async def human_scroll(
page, direction: str = "down", amount: Optional[int] = None
) -> None:
"""模拟人工滚动(分段 + 变速 + 微延迟)。
Args:
page: Playwright Page 对象。
direction: "up""down"
amount: 滚动像素总量,默认随机 300~800。
"""
if amount is None:
amount = random.randint(300, 800)
sign = -1 if direction == "up" else 1
remaining = amount
step_count = random.randint(3, 7)
for i in range(step_count):
step = remaining // (step_count - i) + random.randint(-30, 30)
step = max(10, min(step, remaining))
remaining -= step
await page.mouse.wheel(0, sign * step)
await asyncio.sleep(random.uniform(0.05, 0.2))
if remaining > 0:
await page.mouse.wheel(0, sign * remaining)
await human_delay_short()
# ── 进场鼠标随机晃动 ────────────────────────────────────────
async def human_mouse_wiggle(page) -> None:
"""页面进入后随机移动鼠标 2~4 次(贝塞尔轨迹),模拟真人到场环顾。"""
try:
vp = page.viewport_size or {"width": 1280, "height": 800}
except Exception:
vp = {"width": 1280, "height": 800}
w, h = vp.get("width", 1280), vp.get("height", 800)
for _ in range(random.randint(2, 4)):
tx = random.uniform(w * 0.2, w * 0.8)
ty = random.uniform(h * 0.2, h * 0.7)
await human_mouse_move(page, tx, ty)
await asyncio.sleep(random.uniform(0.2, 0.8))
# ── 拟人逐字输入 ────────────────────────────────────────────
async def human_type(page, locator, text: str) -> None:
"""真人式输入:贝塞尔移动到输入框 → 真实点击聚焦 → 逐字符 keyboard.type。
全程使用 isTrusted=true 的可信事件,绝不用 JS 设置 value。
"""
box = await locator.bounding_box()
if box is None:
raise ValueError("human_type: 无法获取输入框边界")
tx = box["x"] + box["width"] * random.uniform(0.3, 0.7)
ty = box["y"] + box["height"] * random.uniform(0.3, 0.7)
await human_mouse_move(page, tx, ty)
await asyncio.sleep(random.uniform(0.15, 0.5))
await page.mouse.click(tx, ty)
await asyncio.sleep(random.uniform(0.2, 0.6))
# 清空已有内容(用键盘,不用 JS
await page.keyboard.press("Control+A")
await page.keyboard.press("Delete")
await asyncio.sleep(random.uniform(0.2, 0.5))
for ch in text:
await page.keyboard.type(ch, delay=random.uniform(90, 240))

View File

@@ -0,0 +1,29 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""RPA 失败存证路径与截图。"""
from __future__ import annotations
import os
from datetime import datetime
def _artifacts_enabled() -> bool:
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
"""返回 {data_dir}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.{ext}"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
directory = os.path.join(data_dir, "rpa-artifacts", batch_id)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, f"{tag}_{ts}.{ext}")
async def capture_failure(page, data_dir: str, batch_id: str, tag: str) -> str:
"""受 OPENCLAW_ARTIFACTS_ON_FAILURE 控制,截图并返回路径;禁用时返回空字符串。"""
if not _artifacts_enabled():
return ""
path = artifact_path(data_dir, batch_id, tag, ext="png")
await page.screenshot(path=path, full_page=True)
return path

View File

@@ -0,0 +1,58 @@
"""统一 persistent context 启动封装(仅浏览器自动化,不负责录屏)。"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ..runtime_env import find_chrome_executable
from .stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
if TYPE_CHECKING:
from playwright.async_api import BrowserContext
async def launch_persistent_browser(
playwright,
*,
profile_dir: str,
channel: str = "chrome",
executable_path: str | None = None,
headless: bool | None = None,
extra_args: list[str] | None = None,
) -> "BrowserContext":
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
录屏由 RpaVideoSessionffmpeg负责本函数不向 Playwright 传递任何录屏参数。
"""
if headless is None:
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
headless = v in ("1", "true", "yes", "on")
chrome = executable_path or find_chrome_executable()
if not chrome:
raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
)
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
launch_kwargs: dict = dict(
user_data_dir=profile_dir,
headless=headless,
executable_path=chrome,
locale="zh-CN",
no_viewport=True,
args=args,
)
if ignore is not None:
launch_kwargs["ignore_default_args"] = ignore
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
if stealth_enabled():
await context.add_init_script(STEALTH_INIT_SCRIPT)
return context

View File

@@ -0,0 +1,20 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""RPA 场景统一错误码stdout / 异常消息前缀)。"""
ERR_REQUIRE_LOGIN = "ERROR:REQUIRE_LOGIN"
ERR_LOGIN_TIMEOUT = "ERROR:LOGIN_TIMEOUT"
ERR_CAPTCHA_NEED_HUMAN = "ERROR:CAPTCHA_NEED_HUMAN"
ERR_RATE_LIMITED = "ERROR:RATE_LIMITED"
ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
__all__ = [
"ERR_REQUIRE_LOGIN",
"ERR_LOGIN_TIMEOUT",
"ERR_CAPTCHA_NEED_HUMAN",
"ERR_RATE_LIMITED",
"ERR_MISSING_BROWSER",
"ERR_DEVICE_NOT_READY",
"ERR_WINDOW_NOT_FOUND",
]

View File

@@ -0,0 +1,97 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""验证码/登录 HITL 等待(站点无关通用部分)。"""
from __future__ import annotations
import asyncio
DEFAULT_CAPTCHA_MARKERS = (
"punish",
"baxia",
"nc_login",
"tmd",
"x5sec",
"安全验证",
"请按住滑块",
"拖动到最右边",
)
async def wait_for_captcha_pass(
page,
captcha_markers: tuple[str, ...] = DEFAULT_CAPTCHA_MARKERS,
timeout_sec: int = 120,
) -> bool:
"""
检测到验证码/拦截页时,暂停等待用户手工完成验证。
每 3 秒轮询一次页面状态,直到页面不再是拦截状态或超时。
Returns:
True — 验证码已通过,可以继续
False — 超时,用户未在规定时间内完成验证
"""
print(
f"[验证码] 检测到安全拦截,请在浏览器中手工完成验证。"
f"等待最多 {timeout_sec} 秒..."
)
elapsed = 0
while elapsed < timeout_sec:
await asyncio.sleep(3)
elapsed += 3
try:
url = page.url or ""
text = ""
try:
text = await page.locator("body").inner_text(timeout=2_000)
except Exception:
pass
haystack = (url + " " + text[:1000]).lower()
still_blocked = any(m.lower() in haystack for m in captcha_markers)
if not still_blocked:
print(f"[验证码] 已通过!({elapsed}s 后检测到正常页面)")
return True
except Exception:
pass
print(f"[验证码] 超时 {timeout_sec}s用户未完成验证")
return False
async def wait_for_login(
page,
success_selectors: list[str],
timeout_sec: int = 180,
) -> bool:
"""
轮询等待登录成功标志出现(站点专属选择器由调用方传入)。
Args:
page: Playwright Page 对象。
success_selectors: 登录成功后应出现的 CSS 选择器列表。
timeout_sec: 最长等待秒数。
Returns:
True — 检测到登录成功标志
False — 超时
"""
print(
f"[登录] 请在浏览器中完成登录。等待最多 {timeout_sec} 秒,"
"期间程序不会操作页面..."
)
elapsed = 0
while elapsed < timeout_sec:
await asyncio.sleep(2)
elapsed += 2
for selector in success_selectors:
try:
el = await page.query_selector(selector)
if el is not None:
print("[登录] 检测到登录成功!")
return True
except Exception:
pass
print(f"[登录] 超时 {timeout_sec}s未完成登录")
return False

View File

@@ -0,0 +1,67 @@
# vendored from jiangchang-platform-kit; do not edit here, edit the kit.
"""淡化 Playwright 自动化指纹,降低 baxia/x5sec 风控命中率。
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0或 false/off/no
"""
from __future__ import annotations
import os
STEALTH_INIT_SCRIPT = """
(() => {
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
try {
window.chrome = window.chrome || {};
window.chrome.runtime = window.chrome.runtime || {};
} catch (e) {}
try {
const orig = navigator.permissions && navigator.permissions.query;
if (orig) {
navigator.permissions.query = (p) =>
p && p.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: orig(p);
}
} catch (e) {}
try {
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
} catch (e) {}
try {
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
} catch (e) {}
try {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
} catch (e) {}
try {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
} catch (e) {}
try {
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (p) {
if (p === 37445) return 'Intel Inc.';
if (p === 37446) return 'Intel Iris OpenGL Engine';
return getParameter.call(this, p);
};
} catch (e) {}
})();
"""
def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts(
*, extra_args: list[str] | None = None
) -> tuple[list[str], list[str] | None]:
"""返回 (args, ignore_default_args)。ignore_default_args 为 None 时不要传给 launch。"""
args = ["--start-maximized"]
if extra_args:
args = args + list(extra_args)
if not stealth_enabled():
return args, None
if "--disable-blink-features=AutomationControlled" not in args:
args.append("--disable-blink-features=AutomationControlled")
return args, ["--enable-automation"]

View File

@@ -0,0 +1,461 @@
"""RPA 运行录屏会话ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
from __future__ import annotations
import asyncio
import os
import random
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, IO, List, Optional
from .. import config
_JIANGCHANG_SUBTITLE_STYLE = (
"FontName=Arial,FontSize=14,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
"Outline=2,Shadow=1,Alignment=2"
)
_DEFAULT_MUSIC_ROOT = os.environ.get(
"MEDIA_ASSETS_ROOT",
r"D:\OpenClaw\client-commons\media-assets",
)
_CAPTURE_FRAMERATE = 15
def _record_video_enabled() -> bool:
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
def _resolve_music_file() -> Optional[Path]:
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
if not music_dir.is_dir():
return None
files = list(music_dir.rglob("*.mp3"))
if not files:
return None
return random.choice(files)
@dataclass
class _StepEntry:
start: float
text: str
duration: float = 4.0
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
MIN_DURATION = 1.0
GAP = 0.1
steps = sorted(entries, key=lambda e: e.start)
if not steps:
steps = [_StepEntry(0.0, "任务执行中", 4.0)]
for i in range(len(steps) - 1):
cur, nxt = steps[i], steps[i + 1]
max_end = nxt.start - GAP
new_dur = max_end - cur.start
if new_dur < MIN_DURATION:
cur.duration = MIN_DURATION
nxt.start = cur.start + MIN_DURATION + GAP
else:
cur.duration = min(cur.duration, new_dur)
def _fmt(s: float) -> str:
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
ms = int((s % 1) * 1000)
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
for i, e in enumerate(steps, 1):
f.write(f"{i}\n")
f.write(f"{_fmt(e.start)} --> {_fmt(e.start + e.duration)}\n")
f.write(f"{e.text}\n\n")
def _escape_srt_path(path: str) -> str:
return path.replace("\\", "/").replace(":", "\\:")
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError:
return False, "ffmpeg not found"
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
return False, err[:500] if err else f"exit {result.returncode}"
return True, ""
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
if sys.platform != "win32":
return None
return [
"ffmpeg",
"-y",
"-f",
"gdigrab",
"-framerate",
str(_CAPTURE_FRAMERATE),
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
capture_path,
]
def _compose_capture_mp4(
capture_path: Path,
srt_path: Path,
output_path: Path,
*,
music_file: Optional[Path] = None,
music_volume: float = 0.15,
) -> tuple[bool, str]:
output_path.parent.mkdir(parents=True, exist_ok=True)
srt_esc = _escape_srt_path(str(srt_path.resolve()))
subtitle_filter = (
f"subtitles='{srt_esc}'"
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
)
if music_file and music_file.is_file():
filter_complex = (
f"[0:v]{subtitle_filter}[vout];"
f"[1:a]volume={music_volume},apad[aout]"
)
cmd = [
"ffmpeg",
"-y",
"-i",
str(capture_path),
"-i",
str(music_file),
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-pix_fmt",
"yuv420p",
str(output_path),
]
else:
cmd = [
"ffmpeg",
"-y",
"-i",
str(capture_path),
"-vf",
subtitle_filter,
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-pix_fmt",
"yuv420p",
str(output_path),
]
return _run_ffmpeg(cmd)
class RpaVideoSession:
"""Service/RPA 层统一 ffmpeg 桌面录屏OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
def __init__(
self,
*,
skill_slug: str,
skill_data_dir: str,
batch_id: str,
title: str = "",
) -> None:
self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id
self.title = title
self.enabled = _record_video_enabled()
self.warnings: List[str] = []
self.output_video_path: Optional[str] = None
self.capture_path: Optional[str] = None
self.artifacts: Dict[str, Any] = {}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_batch = "".join(c if c.isalnum() or c in "-_" else "_" for c in batch_id)
self._video_stem = f"{skill_slug}_{ts}_{safe_batch}"
self._videos_dir = os.path.join(self.skill_data_dir, "videos")
self._artifact_root = os.path.join(
self.skill_data_dir, "rpa-artifacts", batch_id
)
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
self._logs_dir = os.path.join(self._artifact_root, "logs")
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
self._steps: List[_StepEntry] = []
self._t0: Optional[float] = None
self._ffmpeg_proc: Optional[subprocess.Popen] = None
self._ffmpeg_record_log_file: Optional[IO[str]] = None
if self.enabled:
os.makedirs(self._videos_dir, exist_ok=True)
os.makedirs(self._subtitles_dir, exist_ok=True)
os.makedirs(self._logs_dir, exist_ok=True)
os.makedirs(self._artifact_root, exist_ok=True)
def _record_log_hint(self) -> str:
if self.enabled and self._ffmpeg_record_log_path:
return f"; record_log={self._ffmpeg_record_log_path}"
return ""
def _append_warning(self, code: str, detail: str = "") -> None:
msg = code if not detail else f"{code}: {detail}"
if code in (
"ffmpeg_record_start_failed",
"ffmpeg_capture_missing",
"ffmpeg_compose_failed",
):
msg += self._record_log_hint()
self.warnings.append(msg)
def step(self, text: str, *, duration: float = 4.0) -> None:
self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(相对录屏开始时间)。"""
if not self.enabled:
return
if self._t0 is None:
self._t0 = time.monotonic()
elapsed = time.monotonic() - self._t0
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
def _close_ffmpeg_record_log(self) -> None:
fh = self._ffmpeg_record_log_file
self._ffmpeg_record_log_file = None
if fh is None:
return
try:
fh.close()
except Exception:
pass
def _start_ffmpeg_capture(self) -> None:
if not self.enabled:
return
if not shutil.which("ffmpeg"):
self.warnings.append("ffmpeg_not_found")
return
cmd = _build_desktop_record_cmd(self._capture_path)
if cmd is None:
self._append_warning(
"ffmpeg_record_start_failed",
"desktop capture only supported on Windows",
)
return
stderr_target: Any = subprocess.DEVNULL
try:
os.makedirs(self._logs_dir, exist_ok=True)
self._ffmpeg_record_log_file = open(
self._ffmpeg_record_log_path, "a", encoding="utf-8"
)
stderr_target = self._ffmpeg_record_log_file
except Exception as exc:
self.warnings.append(f"ffmpeg_record_log_open_failed: {exc}")
try:
self._ffmpeg_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=stderr_target,
)
self.capture_path = self._capture_path
except Exception as exc:
self._append_warning("ffmpeg_record_start_failed", str(exc))
self._ffmpeg_proc = None
self._close_ffmpeg_record_log()
def _stop_ffmpeg_capture(self) -> None:
proc = self._ffmpeg_proc
if proc is None:
self._close_ffmpeg_record_log()
return
if proc.poll() is None:
try:
if proc.stdin:
proc.stdin.write(b"q")
proc.stdin.flush()
except Exception:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
self._ffmpeg_proc = None
self._close_ffmpeg_record_log()
time.sleep(0.3)
async def __aenter__(self) -> "RpaVideoSession":
if self.enabled:
self._t0 = time.monotonic()
if self.title:
self.add_step(self.title, duration=3.0)
self._start_ffmpeg_capture()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if self.enabled:
self._stop_ffmpeg_capture()
await self.finalize()
async def finalize(self) -> None:
"""停止录制后合成最终 MP4失败只记 warning不抛异常。"""
if not self.enabled:
self.artifacts = self.summary()
return
if not self._steps and self.title:
self.add_step(self.title)
try:
capture = Path(self._capture_path)
if not capture.is_file() or capture.stat().st_size <= 0:
self._append_warning("ffmpeg_capture_missing")
self.artifacts = self.summary()
return
self.capture_path = str(capture.resolve())
entries = list(self._steps)
if entries:
entries[-1].duration = max(entries[-1].duration, 5.0)
try:
_generate_srt(entries, self._srt_path)
except Exception as exc:
self.warnings.append(f"subtitle_write_failed: {exc}")
music = _resolve_music_file()
if music is None:
self.warnings.append("background_music_missing")
srt_file = Path(self._srt_path)
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
ok, err = _compose_capture_mp4(
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
music_file=music,
)
if not ok and music is not None:
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
ok, err = _compose_capture_mp4(
capture,
srt_file if use_srt else Path(self._srt_path),
Path(self._planned_output),
music_file=None,
)
if not ok and not use_srt:
ok, err = _run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(capture),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
str(self._planned_output),
]
)
if not ok:
self._append_warning("ffmpeg_compose_failed", err)
self.artifacts = self.summary()
return
self.output_video_path = str(Path(self._planned_output).resolve())
except Exception as exc:
self.warnings.append(f"video_finalize_error: {exc}")
self.artifacts = self.summary()
def summary(self) -> Dict[str, Any]:
record_log = (
os.path.abspath(self._ffmpeg_record_log_path)
if self.enabled and self._ffmpeg_record_log_path
else None
)
return {
"enabled": self.enabled,
"path": self.output_video_path,
"capture_path": self.capture_path,
"record_log_path": record_log,
"warnings": list(self.warnings),
}
def to_artifact_dict(self) -> Dict[str, Any]:
data = self.summary()
data["batch_id"] = self.batch_id
return data

View File

@@ -43,14 +43,10 @@ def get_user_id() -> str:
def _looks_like_skills_root(path: str) -> bool: def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path): if not path or not os.path.isdir(path):
return False return False
# 通过常见基础技能存在与否,判断这是不是 skills 根目录
# 这里列举的是平台基础设施类技能,不包含具体业务技能
for marker in ( for marker in (
"llm-manager", "llm-manager",
"content-manager",
"account-manager",
"sohu-publisher",
"toutiao-publisher",
"gongzhonghao-publisher",
"weibo-publisher",
"skill-template", "skill-template",
): ):
if os.path.isdir(os.path.join(path, marker)): if os.path.isdir(os.path.join(path, marker)):

View File

@@ -1,5 +1,5 @@
""" """
统一文件日志:{DATA_ROOT}/{USER_ID}/logs/jiangchang.log 统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。 按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。
""" """
@@ -10,31 +10,55 @@ import os
import sys import sys
import uuid import uuid
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from typing import Optional from typing import Optional, Tuple
from .runtime_env import get_data_root, get_user_id from .runtime_env import get_data_root, get_user_id
_skill_slug: str = "" _skill_slug: str = ""
_logger_name: str = "" _logger_name: str = ""
# 避免 logging 内部错误再向 stderr 打印 "--- Logging error ---"
logging.raiseExceptions = False
def get_unified_logs_dir() -> str: def get_unified_logs_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), "logs") path = os.path.join(get_data_root(), get_user_id(), "logs")
os.makedirs(path, exist_ok=True) try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
return path return path
def get_skill_log_file_path() -> str: def get_skill_log_file_path() -> str:
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip() override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
if override: if override:
parent = os.path.dirname(os.path.abspath(override)) parent = os.path.dirname(os.path.abspath(override))
if parent: if parent:
os.makedirs(parent, exist_ok=True) try:
os.makedirs(parent, exist_ok=True)
except OSError:
pass
return os.path.abspath(override) return os.path.abspath(override)
return os.path.join(get_unified_logs_dir(), "jiangchang.log") return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def _can_open_log_file(log_path: str) -> Tuple[bool, str]:
"""探测日志文件是否可追加写入(真实 open非 os.access"""
try:
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
with open(log_path, "a", encoding="utf-8"):
pass
return True, ""
except Exception as exc:
return False, str(exc)
def ensure_trace_for_process() -> str: def ensure_trace_for_process() -> str:
"""本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承"""
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if existing: if existing:
return existing return existing
@@ -44,8 +68,11 @@ def ensure_trace_for_process() -> str:
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict: def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
ensure_trace_for_process() ensure_trace_for_process()
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or ensure_trace_for_process() tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if not tid:
tid = ensure_trace_for_process()
base = os.environ if environ is None else environ base = os.environ if environ is None else environ
return {**base, "JIANGCHANG_TRACE_ID": tid} return {**base, "JIANGCHANG_TRACE_ID": tid}
@@ -57,7 +84,9 @@ class _SkillContextFilter(logging.Filter):
return True return True
_FORMAT = "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s" _FORMAT = (
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
)
_DATEFMT = "%Y-%m-%dT%H:%M:%S" _DATEFMT = "%Y-%m-%dT%H:%M:%S"
@@ -74,35 +103,72 @@ def _backup_count() -> int:
return 30 return 30
def _clear_logger_handlers(log: logging.Logger) -> None:
for h in list(log.handlers):
log.removeHandler(h)
try:
h.close()
except Exception:
pass
def _attach_fallback_handler(log: logging.Logger) -> None:
"""文件日志不可用时仅挂 NullHandler不向 stderr 打印 logging 异常。"""
log.addHandler(logging.NullHandler())
def setup_skill_logging(skill_slug: str, logger_name: str) -> None: def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
"""
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
须在进程早期调用(如 CLI main 在业务日志之前)。
文件日志不可写时降级为 NullHandler不影响 CLI 返回码。
"""
global _skill_slug, _logger_name global _skill_slug, _logger_name
logging.raiseExceptions = False
ensure_trace_for_process() ensure_trace_for_process()
_skill_slug = skill_slug _skill_slug = skill_slug
_logger_name = logger_name _logger_name = logger_name
log = logging.getLogger(logger_name) log = logging.getLogger(logger_name)
if log.handlers: _clear_logger_handlers(log)
return
log.setLevel(_log_level_from_env()) 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) fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt) filt = _SkillContextFilter()
fh.addFilter(_SkillContextFilter())
log.addHandler(fh) log_path = get_skill_log_file_path()
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ("1", "true", "yes", "on"): ok, _err = _can_open_log_file(log_path)
sh = logging.StreamHandler(sys.stderr) if ok:
sh.setLevel(logging.WARNING) try:
sh.setFormatter(fmt) fh = TimedRotatingFileHandler(
sh.addFilter(_SkillContextFilter()) log_path,
log.addHandler(sh) when="midnight",
interval=1,
backupCount=_backup_count(),
encoding="utf-8",
delay=False,
)
fh.setFormatter(fmt)
fh.addFilter(filt)
log.addHandler(fh)
except Exception:
_attach_fallback_handler(log)
else:
_attach_fallback_handler(log)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
try:
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fmt)
sh.addFilter(filt)
log.addHandler(sh)
except Exception:
pass
log.propagate = False log.propagate = False
@@ -119,19 +185,29 @@ def attach_unified_file_handler(
logger_name: str, logger_name: str,
level: int = logging.DEBUG, level: int = logging.DEBUG,
) -> logging.Logger: ) -> logging.Logger:
"""
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
"""
global _skill_slug global _skill_slug
logging.raiseExceptions = False
ensure_trace_for_process() ensure_trace_for_process()
_skill_slug = skill_slug _skill_slug = skill_slug
lg = logging.getLogger(logger_name) lg = logging.getLogger(logger_name)
lg.handlers.clear() _clear_logger_handlers(lg)
lg.setLevel(level) 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) fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt) filt = _SkillContextFilter()
fh.addFilter(_SkillContextFilter())
lg.addHandler(fh) ok, _err = _can_open_log_file(log_path)
if ok:
try:
fh = logging.FileHandler(log_path, encoding="utf-8", delay=False)
fh.setFormatter(fmt)
fh.addFilter(filt)
lg.addHandler(fh)
except Exception:
_attach_fallback_handler(lg)
else:
_attach_fallback_handler(lg)
lg.propagate = False lg.propagate = False
return lg return lg

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,10 +0,0 @@
"""后台自动化占位模块模板。"""
from __future__ import annotations
from typing import Any, Dict
async def publish(account: Dict[str, Any], article: Dict[str, Any], account_id: str) -> str:
_ = (account, article, account_id)
return "ERROR:NOT_IMPLEMENTED 请复制模板后将本文件改名为具体平台模块并实现后台自动化逻辑"

View File

@@ -1,4 +1,8 @@
"""兄弟技能 CLI 调用模板。""" """兄弟技能 CLI 调用模板。
通用工具:通过子进程调用同级其他 skill 的 main.py并解析 JSON 输出。
具体调用哪些兄弟技能,由具体业务 skill 决定(在 task_service.py 中按需 import 调用)。
"""
from __future__ import annotations from __future__ import annotations
@@ -12,7 +16,19 @@ from util.logging_config import subprocess_env_with_trace
from util.runtime_paths import get_skills_root from util.runtime_paths import get_skills_root
def _call_json_script(script_path: str, args: List[str]) -> Optional[Dict[str, Any]]: def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, Any]]:
"""通用兄弟技能调用器。
Args:
skill_slug: 兄弟技能的 slug即同级目录名
args: 传给 main.py 的命令行参数列表。
Returns:
若兄弟技能输出可解析的 JSON 对象则返回 dict否则返回 None。
"""
script_path = os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
if not os.path.isfile(script_path):
return None
proc = subprocess.run( proc = subprocess.run(
[sys.executable, script_path, *args], [sys.executable, script_path, *args],
capture_output=True, capture_output=True,
@@ -31,9 +47,19 @@ def _call_json_script(script_path: str, args: List[str]) -> Optional[Dict[str, A
return data if isinstance(data, dict) else None return data if isinstance(data, dict) else None
def get_account_manager_main_path() -> str: def get_sibling_main_path(skill_slug: str) -> str:
return os.path.join(get_skills_root(), "account-manager", "scripts", "main.py") """获取兄弟技能的 main.py 绝对路径。
使用示例(在你的 task_service.py 中):
from service.sibling_bridge import get_sibling_main_path, call_sibling_json
# 调用名为 account-manager 的兄弟技能:
result = call_sibling_json("account-manager", ["list", "--limit", "10"])
"""
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
def get_content_manager_main_path() -> str: # ============================================================
return os.path.join(get_skills_root(), "content-manager", "scripts", "main.py") # 复制本模板后,按需在你自己的 task_service.py 中调用上面的工具。
# 不要在本文件中硬编码具体兄弟技能函数(如 get_account_manager_main_path
# 那是发布类技能的特定需求,不属于通用模板。
# ============================================================

View File

@@ -1,4 +1,8 @@
"""发布编排、日志查询模板。""" """任务编排、日志查询模板。
通用业务编排层负责参数校验鉴权调用具体业务实现写入任务日志
复制后在 cmd_run 中实现真正的业务逻辑
"""
from __future__ import annotations from __future__ import annotations
@@ -6,39 +10,47 @@ import json
import sys import sys
from typing import Optional from typing import Optional
from db import publish_logs_repository as plr 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 util.constants import SKILL_SLUG, SKILL_VERSION
from util.timeutil import unix_to_iso from util.timeutil import unix_to_iso
def cmd_publish(account_id: Optional[str] = None, article_id: Optional[str] = None) -> int: def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int:
_ = (account_id, article_id) """通用任务执行入口模板。复制后请实现真实业务逻辑。"""
_ = (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
print("❌ 这是模板仓库,请复制后在 scripts/service/ 中实现真正的发布逻辑。") print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return 1 return 1
def cmd_logs(limit: int = 10, status: Optional[str] = None, account_id: Optional[str] = None) -> int: def cmd_logs(
limit: int = 10,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> int:
if limit <= 0: if limit <= 0:
limit = 10 limit = 10
rows = plr.list_publish_logs(limit, status, account_id) rows = tlr.list_task_logs(limit, status, task_type, target_id)
if not rows: if not rows:
print("暂无发布记录") print("暂无任务日志")
return 0 return 0
sep_line = "_" * 39 sep_line = "_" * 39
for idx, r in enumerate(rows): for idx, r in enumerate(rows):
rid, aid, arid, title, st, err, cat, uat = r rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = r
print(f"id{rid}") print(f"id{rid}")
print(f"account_id{aid or ''}") print(f"task_type{ttype or ''}")
print(f"article_id{arid}") print(f"target_id{tid or ''}")
print(f"article_title{title or ''}") print(f"input_id{iid or ''}")
print(f"input_title{ititle or ''}")
print(f"status{st or ''}") print(f"status{st or ''}")
print(f"error_msg{err or ''}") print(f"error_msg{err or ''}")
print(f"result_summary{rsum or ''}")
print(f"created_at{unix_to_iso(cat) or str(cat or '')}") print(f"created_at{unix_to_iso(cat) or str(cat or '')}")
print(f"updated_at{unix_to_iso(uat) or str(uat or '')}") print(f"updated_at{unix_to_iso(uat) or str(uat or '')}")
if idx != len(rows) - 1: if idx != len(rows) - 1:
@@ -51,20 +63,22 @@ def cmd_log_get(log_id: str) -> int:
if not str(log_id).isdigit(): if not str(log_id).isdigit():
print("❌ log_id 必须是数字") print("❌ log_id 必须是数字")
return 1 return 1
row = plr.get_publish_log_by_id(int(log_id)) row = tlr.get_task_log_by_id(int(log_id))
if not row: if not row:
print("❌ 没有这条发布记录") print("❌ 没有这条任务日志")
return 1 return 1
rid, aid, arid, title, st, err, cat, uat = row rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = row
print( print(
json.dumps( json.dumps(
{ {
"id": int(rid), "id": int(rid),
"account_id": aid, "task_type": ttype,
"article_id": int(arid), "target_id": tid,
"article_title": title, "input_id": iid,
"input_title": ititle,
"status": st, "status": st,
"error_msg": err, "error_msg": err,
"result_summary": rsum,
"created_at": unix_to_iso(cat), "created_at": unix_to_iso(cat),
"updated_at": unix_to_iso(uat), "updated_at": unix_to_iso(uat),
}, },

View File

@@ -1,5 +1,5 @@
"""技能标识与版本(复制后请修改)。""" """技能标识与版本(复制后请修改)。"""
SKILL_SLUG = "your-skill-slug" SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.13" SKILL_VERSION = "1.0.14"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"

10
tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# 测试运行产物与本地密钥,勿提交
__pycache__/
*.py[cod]
.pytest_cache/
desktop/artifacts/
integration/artifacts/
*.log
.env
.env.*
!.env.sample

View File

@@ -1,8 +1,173 @@
# tests # skill-template 测试说明(企业数字员工技能)
这里放模板复制后的单元测试或最小回归测试 本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须通过环境变量显式授权,且仅以 ``*.sample`` 或复制后的文件提供范式
模板仓库本身不强制附带业务测试,但建议新 skill 至少补: 复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。
- `health` / `version` 最小冒烟测试 ---
- 关键 `service` 层函数的单元测试
## 我该把测试写在哪里?
人类开发者与 Cursor 等 AI 工具在加用例前,先对照下表决定**文件落点**与**是否默认执行**
| 场景 | 放在哪里 | 默认是否运行 | 说明 |
|------|----------|--------------|------|
| CLI / health / version / metadata / runtime 路径 | `tests/test_*.py` | 是 | 默认必跑,保持轻量、无外联 |
| 纯函数 / 业务规则 | `tests/test_*.py` | 是 | 不依赖真实外部系统 |
| service 层契约 | 从 `tests/samples/test_service_contract.py.sample` 复制到 `tests/test_service_contract.py` | 是,复制后 | 用 `FakeAdapter` / stub 注入外部依赖 |
| golden fixture 回归 | 从 `tests/samples/test_golden_cases.py.sample` 复制到 `tests/test_golden_cases.py` | 是,复制后 | 输入样例 + 期望输出,适合解析、计算、校验类技能 |
| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api`(或兼容键名 `OPENCLOW_TEST_TARGET`)后按需运行 |
| 仿真 RPA / 页面操作 | `tests/integration/test_simulator_rpa_contract.py.sample` | 否 | 用 localhost / 仿真页面,不碰真实系统 |
| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` 且 `ALLOW_REAL_API=1` |
| 真实 RPA | `tests/integration/test_real_rpa_contract.py.sample` | 否 | 最高风险,只能手动触发 |
| 桌面宿主 E2E | `tests/desktop/test_desktop_smoke.py.sample` | 否 | 需要 pytest + `jiangchang_desktop_sdk` |
说明:`python tests/run_tests.py` **只**收集 `tests/` **根目录**下的 `test_*.py`;子目录里的 `*.sample` 与 desktop 用例**不会**被默认发现。
---
## 新技能最小测试清单
从本模板复制出新技能仓库后,建议至少逐项确认:
- [ ] `python tests/run_tests.py -v` 能通过。
- [ ] `python scripts/main.py health` 能通过。
- [ ] `python scripts/main.py version` 输出 JSON且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。
- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。
- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API不打开真实 RPA。
- [ ] 至少有 1 个成功路径测试。
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 如果技能有 Python 三方依赖,`requirements.txt` 已声明且版本约束合理(尽量收窄范围)。
- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
---
## 1. 默认必跑unittest
在**技能仓库根目录**执行:
```powershell
$env:PYTHONDONTWRITEBYTECODE = "1"
python tests/run_tests.py
python tests/run_tests.py -v
python tests/run_tests.py cli_smoke
python tests/run_tests.py test_cli_smoke
```
### 1.1 收集范围
- ``run_tests.py`` **只**发现 ``tests/`` **根目录**下 ``test_*.py``。
- **不递归**``tests/samples/``、``tests/integration/``、``tests/desktop/``(其中文件为 ``*.sample`` 或需 pytest 单独跑)。
- 启动时把 ``scripts/`` 与 ``tests/`` 加入 ``sys.path``。
- Windows 下将 **stdout/stderr** 包装为 **UTF-8**。
### 1.2 默认套件覆盖
| 能力 | 文件 |
|------|------|
| CLI 冒烟import ``cli.app.main`` | ``test_cli_smoke.py`` |
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **CLAW_*/JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.py`` |
| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) |
### 1.3 数据隔离(``_support.IsolatedDataRoot``
同时设置(进入同一临时目录 / 用户):
| ``CLAW_DATA_ROOT`` | ``JIANGCHANG_DATA_ROOT`` |
|--------------------|---------------------------|
| 同一 ``tempfile.mkdtemp(prefix="skill-test-")`` | 同上 |
| ``CLAW_USER_ID`` | ``JIANGCHANG_USER_ID`` |
|------------------|-------------------------|
| ``_test`` | ``_test`` |
退出时四个键均恢复(原不存在则 ``pop``),并删除临时目录。
### 1.4 产物与密钥
- ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。
- 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。
---
## 2. 可选Service / Golden``tests/samples/*.sample``
- 展示 **service 契约** 与 **fixture 回归** 写法;**默认不执行**。
- 启用:复制 ``tests/samples/test_service_contract.py.sample`` 等为 ``tests/test_*.py``(去掉 ``.sample``),按业务修改后再 ``python tests/run_tests.py``。
- 脱敏样例数据见 ``tests/fixtures/README.md`` 与 ``sample_request.json`` / ``expected_response.json``profile 结构见 ``adapter_profiles.sample.yaml``。
---
## 3. 可选Integration``tests/integration/*.sample``
- **默认不跑**;文件均为 ``*.py.sample``。
- 说明与开关见 **`tests/integration/README.md`**。
- **禁止**在样例中硬编码真实 token、密码、生产 URL凭证须来自密钥库或受控环境变量。
---
## 4. 可选Desktop E2Epytest
见 **`tests/desktop/README.md`**。示例为 `test_desktop_smoke.py.sample`,需复制为 `test_desktop_smoke.py` 后用 pytest 运行。
---
## 5. 环境变量:测试档位与授权
### 5.1 测试档位(二选一变量名,后者为历史拼写兼容)
- ``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``
| 取值 | 含义 |
|------|------|
| ``unit`` | **默认**:单元 / 内存 / mock不跑仿真与真实外联 |
| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) |
| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** |
| ``real_api`` | 真实 API另需 ``ALLOW_REAL_API=1`` |
| ``real_rpa`` | 真实 RPA另需 ``ALLOW_REAL_RPA=1`` |
未设置时等价 ``unit``。
### 5.2 授权开关(显式 ``1``
| 变量 | 作用 |
|------|------|
| ``ALLOW_REAL_API=1`` | 允许 ``real_api`` profile 访问真实 HTTP 通道 |
| ``ALLOW_REAL_RPA=1`` | 允许 ``real_rpa`` profile 驱动真实浏览器/RPA |
| ``ALLOW_WRITE_ACTIONS=1`` | 在 ``real_*`` 下允许**写**操作(提交表单、下单等) |
### 5.3 默认安全策略(``adapter_test_utils``
- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。
- ``assert_no_real_network_env()`` 断言默认未误开 ``ALLOW_REAL_*``(用于必跑用例自检)。
---
## AI 编程工具注意事项Cursor 等)
以下约束适用于为本仓库或复制出的技能编写/修改测试时,**避免误伤业务与生产**
- **不要为了**让测试通过去改业务代码,除非用户明确要求修改实现。
- **不要**把真实外部系统调用写进默认的 `tests/test_*.py`(默认套件须无外联、无真实浏览器)。
- **不要**把 `*.sample` 改名为可执行测试或移入根目录并去掉 `.sample`,除非用户明确要求启用 integration / 真实联调。
- **不要**硬编码真实凭证、token、cookie、生产 URL占位请用明显虚构域名如 `example.invalid`)或文档约定的 `credential_ref`。
- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。
- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。
- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` / `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` 等**显式开关**;不得默认开启。
---
## 6. 与 content-manager 的关系
- **未**复制 article / media / prompt / NotebookLM 等业务测试。
- 本骨架面向**通用外联技能**;业务契约请放在各自技能的 ``tests/samples`` / ``tests/integration`` 复制件中实现。

77
tests/_support.py Normal file
View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
测试公共支撑。
1. 将 scripts/ 加入 sys.path便于 `from cli…` / `from db…` / `from util…` 等导入。
2. `IsolatedDataRoot`:同时设置 CLAW_* 与 JIANGCHANG_* 数据根与用户 ID
与 `jiangchang_skill_core.runtime_env` 的优先级一致CLAW_* 优先),
避免只设 JIANGCHANG_* 时仍读到真实 CLAW_DATA_ROOT。
退出时四个变量均恢复;原不存在的键会删除;并 rmtree 临时目录。
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from typing import Optional
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, ".."))
_SCRIPTS_DIR = os.path.join(_SKILL_ROOT, "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
_ISOLATION_KEYS = (
"CLAW_DATA_ROOT",
"JIANGCHANG_DATA_ROOT",
"CLAW_USER_ID",
"JIANGCHANG_USER_ID",
)
def get_scripts_dir() -> str:
return _SCRIPTS_DIR
def get_skill_root() -> str:
return _SKILL_ROOT
class IsolatedDataRoot:
"""
在独立临时目录下模拟数据根,避免污染本机默认数据目录。
进入上下文时(同一套隔离值写入 CLAW_* 与 JIANGCHANG_*
- CLAW_DATA_ROOT / JIANGCHANG_DATA_ROOT -> 同一 tempfile.mkdtemp(prefix='skill-test-')
- CLAW_USER_ID / JIANGCHANG_USER_ID -> _test与业务默认用户区分
退出时:四个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
"""
def __init__(self, user_id: str = "_test") -> None:
self._tmp: Optional[str] = None
self._old: dict[str, Optional[str]] = {}
self._user_id = user_id
def __enter__(self) -> str:
self._tmp = tempfile.mkdtemp(prefix="skill-test-")
for k in _ISOLATION_KEYS:
self._old[k] = os.environ.get(k)
os.environ["CLAW_DATA_ROOT"] = self._tmp
os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp
os.environ["CLAW_USER_ID"] = self._user_id
os.environ["JIANGCHANG_USER_ID"] = self._user_id
return self._tmp
def __exit__(self, *exc_info: object) -> None:
for k in _ISOLATION_KEYS:
v = self._old.get(k)
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
if self._tmp and os.path.isdir(self._tmp):
shutil.rmtree(self._tmp, ignore_errors=True)
self._tmp = None

142
tests/adapter_test_utils.py Normal file
View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
企业技能对接外部系统ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA
测试侧共用的 **profile / 授权 / 禁止真实外呼** 工具。
- 仅标准库;不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并配合环境变量显式授权。
"""
from __future__ import annotations
import os
from typing import Any, Final
# 允许的 OPENCLAW_TEST_TARGET / OPENCLOW_TEST_TARGET 取值(后者为历史拼写兼容)
ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
{
"unit",
"mock",
"simulator_api",
"simulator_rpa",
"real_api",
"real_rpa",
}
)
def _raw_test_target_env() -> str:
return (os.environ.get("OPENCLAW_TEST_TARGET") or os.environ.get("OPENCLOW_TEST_TARGET") or "").strip()
def get_test_target() -> str:
"""
读取 ``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``(兼容旧拼写)。
未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。
"""
raw = _raw_test_target_env()
if not raw:
return "unit"
if raw not in ALLOWED_TEST_TARGETS:
raise ValueError(
f"Invalid test target {raw!r}. Allowed: {sorted(ALLOWED_TEST_TARGETS)}"
)
return raw
def real_access_allowed(profile: str) -> bool:
"""
判断当前环境是否允许对 ``profile`` 做「真实通道」访问(仍不代替业务鉴权)。
- ``real_api``:需要 ``ALLOW_REAL_API == "1"``。
- ``real_rpa``:需要 ``ALLOW_REAL_RPA == "1"``。
- 写操作另需 ``ALLOW_WRITE_ACTIONS == "1"``(由 ``should_skip_profile(..., write=True)`` 组合检查)。
"""
if profile == "real_api":
return os.environ.get("ALLOW_REAL_API", "") == "1"
if profile == "real_rpa":
return os.environ.get("ALLOW_REAL_RPA", "") == "1"
if profile in ("mock", "simulator_api", "simulator_rpa"):
return True
raise ValueError(f"unknown profile for real_access_allowed: {profile!r}")
def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]:
"""
返回 ``(should_skip, reason)``。
策略摘要:
- ``mock``:永不跳过(内存 / FakeAdapter无外呼
- ``simulator_api``:仅当 ``get_test_target() == "simulator_api"`` 时不跳过。
- ``simulator_rpa``:仅当 target 为 ``simulator_rpa`` 时不跳过。
- ``real_api``target 须为 ``real_api`` 且 ``ALLOW_REAL_API=1````write=True`` 还须 ``ALLOW_WRITE_ACTIONS=1``。
- ``real_rpa``target 须为 ``real_rpa`` 且 ``ALLOW_REAL_RPA=1````write=True`` 还须 ``ALLOW_WRITE_ACTIONS=1``。
"""
target = get_test_target()
if profile == "mock":
return (False, "")
if profile == "simulator_api":
if target != "simulator_api":
return (True, f"OPENCLAW_TEST_TARGET must be simulator_api (got {target!r})")
return (False, "")
if profile == "simulator_rpa":
if target != "simulator_rpa":
return (True, f"OPENCLAW_TEST_TARGET must be simulator_rpa (got {target!r})")
return (False, "")
if profile == "real_api":
if target != "real_api":
return (True, f"OPENCLAW_TEST_TARGET must be real_api (got {target!r})")
if os.environ.get("ALLOW_REAL_API", "") != "1":
return (True, "ALLOW_REAL_API is not 1")
if write and os.environ.get("ALLOW_WRITE_ACTIONS", "") != "1":
return (True, "ALLOW_WRITE_ACTIONS is not 1 (write requested)")
return (False, "")
if profile == "real_rpa":
if target != "real_rpa":
return (True, f"OPENCLAW_TEST_TARGET must be real_rpa (got {target!r})")
if os.environ.get("ALLOW_REAL_RPA", "") != "1":
return (True, "ALLOW_REAL_RPA is not 1")
if write and os.environ.get("ALLOW_WRITE_ACTIONS", "") != "1":
return (True, "ALLOW_WRITE_ACTIONS is not 1 (write requested)")
return (False, "")
raise ValueError(f"unknown profile: {profile!r}")
class FakeAdapter:
"""
模拟外部系统 adapter用于单元测试中的超时 / 鉴权 / 畸形响应等分支。
``mode````success`` | ``timeout`` | ``invalid_response`` | ``unauthorized``
"""
def __init__(self, mode: str = "success") -> None:
if mode not in ("success", "timeout", "invalid_response", "unauthorized"):
raise ValueError(f"unsupported FakeAdapter mode: {mode!r}")
self._mode = mode
def call(self, payload: Any) -> Any:
if self._mode == "success":
return {"success": True, "data": {"echo": payload}}
if self._mode == "timeout":
raise TimeoutError("simulated upstream timeout")
if self._mode == "invalid_response":
return object() # 非 dict模拟无法解析的响应体
if self._mode == "unauthorized":
raise PermissionError("simulated 401/403")
raise RuntimeError("unreachable")
def assert_no_real_network_env() -> None:
"""
断言当前进程未显式打开「真实外呼」授权开关(默认安全 CI / 本地开发)。
若需在受控环境跑真实集成,请显式设置 ``ALLOW_REAL_*`` 并使用 ``tests/integration/*.sample``。
"""
assert os.environ.get("ALLOW_REAL_API", "") != "1", "ALLOW_REAL_API must not be 1 in default unit tests"
assert os.environ.get("ALLOW_REAL_RPA", "") != "1", "ALLOW_REAL_RPA must not be 1 in default unit tests"

63
tests/desktop/README.md Normal file
View File

@@ -0,0 +1,63 @@
# 可选桌面 E2Epytest
本目录用于**匠厂桌面 + jiangchang_desktop_sdk** 的端到端冒烟,**不是** `python tests/run_tests.py` 默认路径的一部分。
## 依赖
- `pytest`
- `jiangchang_desktop_sdk`(由 `conftest.py` 按多级路径尝试加入 `sys.path`,见该文件说明)
- 匠厂桌面已安装或开发态可启动,且 `jiangchang://` 协议可用
## 为什么不默认跑
- 需要真实宿主环境,无法在纯 CI/无头环境稳定复现。
- `unittest``tests/run_tests.py` **只发现** `tests/` 根目录下 `test_*.py`**不递归**本目录。
- 示例文件为 **`test_desktop_smoke.py.sample`**,避免被误收集。
## 如何启用
1.`test_desktop_smoke.py.sample` **复制或重命名**为 `test_desktop_smoke.py`
2. 按你的技能名称、路由关键词、期望输出修改 `ask` 与断言。
3. 运行:
```powershell
pytest tests/desktop/test_desktop_smoke.py -v -s
```
失败时,`conftest.py` 中的 `failure_snapshot_dir` fixture 会将现场目录指向 `tests/desktop/artifacts/`(需自行避免将大文件提交进仓库)。
## 与后端测试的关系
- **后端 / CLI / DB**:请使用仓库根目录下 `python tests/run_tests.py`(见 `tests/README.md`)。
- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。
若仅需验证 **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)` 给个别字幕指定独立时长,
覆盖长等待场景中间没日志输出的"字幕真空"

88
tests/desktop/conftest.py Normal file
View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2Epytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。
查找顺序:
1. 已 pip install 则直接 import
2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src旧版布局
3. 环境变量 JIANGCHANG_KIT_ROOT → src新版 platform-kit 布局);
4. 自本文件上溯skill 根 → workspace 根 → OpenClaw 根,拼路径;
5. 兜底 D:\\AI\\jiangchang-platform-kit\\src若存在
6. 都找不到则 pytest.exit 给出可操作的排障说明。
"""
from __future__ import annotations
import importlib.util
import os
import sys
import pytest
def _bootstrap_jiangchang_desktop_sdk() -> None:
if importlib.util.find_spec("jiangchang_desktop_sdk") is not None:
return
env_kit_root = (os.environ.get("JIANGCHANG_KIT_ROOT") or "").strip()
here = os.path.dirname(os.path.abspath(__file__))
skill_root = os.path.abspath(os.path.join(here, "..", ".."))
workspace_root = os.path.abspath(os.path.join(skill_root, ".."))
open_claw_root = os.path.abspath(os.path.join(workspace_root, ".."))
candidates = []
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, "src"))
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
)
candidates.append(
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 = []
for cand in candidates:
cand_abs = os.path.abspath(cand)
tried.append(cand_abs)
if os.path.isdir(cand_abs) and os.path.isdir(os.path.join(cand_abs, "jiangchang_desktop_sdk")):
if cand_abs not in sys.path:
sys.path.insert(0, cand_abs)
return
pytest.exit(
"无法定位 jiangchang_desktop_sdk。请任选一种方式解决\n"
" 1) pip install -e <path-to>/jiangchang-platform-kit\n"
" 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n"
" SDK 新版位置:<root>\\src旧版<root>\\sdk\\jiangchang-desktop-sdk\\src\n"
" 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n"
"已尝试的候选路径:\n - " + "\n - ".join(tried),
returncode=4,
)
_bootstrap_jiangchang_desktop_sdk()
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture
def failure_snapshot_dir() -> str:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
return _ARTIFACT_DIR

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

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample—— **简单问答场景**。
启用方式:
复制本文件为 test_desktop_smoke.py按目标技能修改提问文案与断言。
运行pytest tests/desktop/test_desktop_smoke.py -v -s
依赖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
import logging
import os
import pytest
from jiangchang_desktop_sdk import AskOptions, JiangchangDesktopClient
_logger = logging.getLogger("skill-template-desktop-e2e-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E-sample] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
class TestDesktopSmokeSample:
"""最小 smoke拉起桌面、等待网关、发一条与技能路由相关的问句并做宽松断言。"""
@pytest.fixture(autouse=True)
def connect_and_teardown(self, request):
client = JiangchangDesktopClient()
_logger.info("ensure_app_running")
client.ensure_app_running(wait_timeout_s=30)
client.bring_to_front()
client.wait_gateway_ready(timeout_ms=30000)
self.client = client
yield
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = client.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("snapshot: %s", paths)
finally:
client.disconnect()
def test_gateway_and_simple_ask(self) -> None:
"""请将 SKILL 名与路由关键词改成你复制后的真实技能。"""
answer = self.client.ask(
"请使用当前已安装技能执行一次 health 检查并返回 OK 或等价结果。",
AskOptions(timeout=120000, new_task=True),
)
assert answer
assert "OK" in answer or "ok" in answer.lower() or "健康" in answer

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}"

27
tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# fixtures脱敏样例
本目录存放 **golden case / contract test** 用的输入、期望输出与 **adapter profile 样例**
## 规则
- 只能放**虚构、脱敏、可公开**的数据(演示租户、演示 request_id 等)。
- **禁止**:真实客户名、真实订单/提单号、真实报关单、真实 token/cookie、真实内网 URL、生产环境凭证。
- `adapter_profiles.sample.yaml` 中的 URL 须使用 `localhost``example.invalid` 等明显占位;`credential_ref` 仅表达「从密钥库引用」的约定,**不得**内嵌密钥。
## 与本仓库测试的对应关系
| 文件 | 用途 |
|------|------|
| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 |
| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 |
| `adapter_profiles.sample.yaml` | 五类运行方式mock / simulator_api / simulator_rpa / real_api / real_rpa的配置表达样例。 |
---
## 给 AI 工具的替换规则
- 可以按业务**重命名字段**、增减键名,但必须保持**虚构、脱敏**;不得把真实客户标识原样搬进仓库。
- 可以**新增**更多 fixture 文件,例如 `case_valid_001.json`、`case_missing_required_001.json`,并在测试中通过相对路径读取。
- **禁止**将真实企业数据、真实单证截图中的文字、真实报关要素未经处理直接复制进 `fixtures/`。
- 若用例来自真实客户样本,须先**脱敏、泛化、替换编号与地名**,再作为模板数据提交。

View File

@@ -0,0 +1,40 @@
# 样例:五类 adapter 运行方式(复制到业务仓后按环境改名;勿提交真实凭证)
runtime:
profile: mock
adapters:
demo_external_system:
interface: demo
profile: mock
mock:
target: mock
channel: none
simulator_api:
target: simulator
channel: api
base_url: http://localhost:5180/api/demo
timeout_seconds: 10
simulator_rpa:
target: simulator
channel: rpa
entry_url: http://localhost:5180/select
timeout_seconds: 30
real_api:
target: real
channel: api
base_url: https://example.invalid/api
credential_ref: vault://tenant/demo/external-system/api
write_enabled: false
timeout_seconds: 20
real_rpa:
target: real
channel: rpa
entry_url: https://example.invalid/portal
credential_ref: vault://tenant/demo/external-system/rpa
write_enabled: false
timeout_seconds: 60

6
tests/fixtures/expected_response.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"success": true,
"data": {
"status": "ok"
}
}

11
tests/fixtures/sample_request.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"request_id": "req_test_001",
"tenant_id": "tenant_demo",
"actor": "tester",
"parameters": {
"input_text": "demo input",
"options": {
"dry_run": true
}
}
}

View File

@@ -0,0 +1,53 @@
# integration可选集成 / 真实外联)
本目录**默认不执行**
- ``python tests/run_tests.py`` 只收集 ``tests/`` 根目录下 ``test_*.py``**不会**进入本目录。
- 本目录下文件一律为 ``*.py.sample``pytest / unittest **默认也不会**按普通 ``test_*.py`` 收集。
## 与 samples 的区别
| 维度 | `tests/samples/` | `tests/integration/` |
|------|------------------|------------------------|
| 目的 | 默认可**升级**为根目录 `test_*.py`**业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 |
| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API授权、真实 RPA最高风险 |
**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`
## 何时使用
| 场景 | 建议 ``OPENCLAW_TEST_TARGET`` | 其它条件 |
|------|------------------------------|----------|
| 本地 / CI 对接**仿真 HTTP** | ``simulator_api`` | 仿真服务如 ``http://localhost:5180`` |
| **仿真页面** / 录播 RPA | ``simulator_rpa`` | 本地页面或沙箱浏览器 profile |
| **真实 API**(只读优先) | ``real_api`` | ``ALLOW_REAL_API=1``;写操作另需 ``ALLOW_WRITE_ACTIONS=1`` |
| **真实 RPA**(最高风险) | ``real_rpa`` | ``ALLOW_REAL_RPA=1``**仅手动触发** |
## 环境变量(与 ``adapter_test_utils`` 一致)
- **目标档位**(二选一键名,后者为历史拼写):``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
取值:``unit`` | ``mock`` | ``simulator_api`` | ``simulator_rpa`` | ``real_api`` | ``real_rpa``
默认未设置等价于 ``unit``。
- **授权开关**(显式 ``1`` 才启用):
- ``ALLOW_REAL_API=1``
- ``ALLOW_REAL_RPA=1``
- ``ALLOW_WRITE_ACTIONS=1``(对写操作 / 提交表单类步骤)
## 安全约束
- **禁止**在样例或复制后的测试代码中硬编码真实账号、密码、token、cookie、生产 URL。
- 凭证应来自**平台密钥库**、受控环境变量或本机安全配置(``.env`` 已列入 ``tests/.gitignore``,勿提交)。
- 真实 RPA 可能对 ERP/TMS/WMS 等产生真实操作;**务必**沙箱账号、只读权限,并由人工触发 CI job。
## 如何运行某个样例
1. 将目标 ``*.sample`` 复制为 ``test_*.py``(去掉 ``.sample``)。
2. 按技能修改其中的占位 URL / 路由 / 断言。
3. 使用 **pytest** 指向该文件(或整目录),并导出上表所需环境变量。
```powershell
$env:OPENCLAW_TEST_TARGET = "simulator_api"
pytest tests/integration/test_simulator_api_contract.py -v
```

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_api_contract.py需 OPENCLAW_TEST_TARGET=real_api 且 ALLOW_REAL_API=1。
"""真实 HTTP API只读优先默认跳过。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestRealApiContractSample(unittest.TestCase):
def test_skip_by_default(self) -> None:
skip, reason = should_skip_profile("real_api", write=False)
if skip:
raise unittest.SkipTest(reason)
# 写操作示例(复制后使用):
# skip_w, _ = should_skip_profile("real_api", write=True)
# 需要 ALLOW_WRITE_ACTIONS=1
# 禁止在此文件写入真实 token从 vault / 环境注入读取 endpoint。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_rpa_contract.py需 OPENCLAW_TEST_TARGET=real_rpa 且 ALLOW_REAL_RPA=1。
"""
真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。
默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。
任何写操作必须 ``should_skip_profile('real_rpa', write=True)`` 且 ``ALLOW_WRITE_ACTIONS=1``。
"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestRealRpaContractSample(unittest.TestCase):
def test_skip_by_default(self) -> None:
skip, reason = should_skip_profile("real_rpa", write=False)
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:连接企业沙箱门户,执行只读断言;禁止默认操作生产系统。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_simulator_api_contract.py 后,在 OPENCLAW_TEST_TARGET=simulator_api 下用 pytest 或 unittest 运行。
"""仿真 HTTP API 契约(默认跳过;不引入 requests 运行时依赖)。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestSimulatorApiContractSample(unittest.TestCase):
"""演示:仅当 ``OPENCLAW_TEST_TARGET=simulator_api`` 时不跳过。"""
def test_skip_unless_simulator_api_target(self) -> None:
skip, reason = should_skip_profile("simulator_api")
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:在此使用 urllib 访问 localhost 仿真,或通过 adapter factory 注入 stub。
# 禁止默认连接生产 ERP/TMS。
self.assertTrue(True)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。
"""仿真页面 / RPA 契约(不默认启动浏览器)。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestSimulatorRpaContractSample(unittest.TestCase):
def test_skip_unless_simulator_rpa_target(self) -> None:
skip, reason = should_skip_profile("simulator_rpa")
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

62
tests/run_tests.py Normal file
View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""后端 / CLI / DB 单元测试入口unittest不强制 pytest
用法:
python tests/run_tests.py # 发现 tests/ 根目录下全部 test_*.py
python tests/run_tests.py -v # 详细输出
python tests/run_tests.py cli_smoke # 仅匹配 test_cli_smoke.py
python tests/run_tests.py test_cli_smoke
说明:
只发现本目录tests/)下一层的 test_*.py不递归 ``samples/``、``integration/``、
``desktop/``(后三者提供 ``*.sample`` 或 pytest 专用用例,见 ``tests/README.md``)。
"""
from __future__ import annotations
import os
import sys
import unittest
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, ".."))
_SCRIPTS_DIR = os.path.abspath(os.path.join(_SKILL_ROOT, "scripts"))
for _p in (_SCRIPTS_DIR, _TESTS_DIR):
if _p not in sys.path:
sys.path.insert(0, _p)
# Windows 下统一 stdout/stderr 为 UTF-8避免 GBK 控制台中文/emoji 报错
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def _pattern_from_argv(argv: list[str]) -> str:
for a in argv[1:]:
if a.startswith("-"):
continue
s = a if a.startswith("test_") else f"test_{a}"
if not s.endswith(".py"):
s = f"{s}.py"
return s
return "test_*.py"
def _verbosity_from_argv(argv: list[str]) -> int:
return 2 if ("-v" in argv or "--verbose" in argv) else 1
def main() -> int:
pattern = _pattern_from_argv(sys.argv)
verbosity = _verbosity_from_argv(sys.argv)
loader = unittest.TestLoader()
suite = loader.discover(_TESTS_DIR, pattern=pattern)
runner = unittest.TextTestRunner(verbosity=verbosity)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
if __name__ == "__main__":
raise SystemExit(main())

112
tests/samples/README.md Normal file
View File

@@ -0,0 +1,112 @@
# samples业务测试样例默认不执行
本目录存放**可复制到根目录的业务测试范式**,面向「企业数字员工技能」中 **ERP、TMS、WMS、船司、报关、邮件、LLM、RPA** 等外联场景下的 **service / golden** 测法说明。
---
## 作用
- 这里的文件是 **`.py.sample`**`python tests/run_tests.py` **不会**收集本目录(`run_tests.py` 只发现 `tests/` 根目录下的 `test_*.py`)。
- 人类或 AI 需要启用范式时:**复制**到 `tests/test_<名称>.py`(去掉 `.sample`),再按业务改 import、断言与 fixture 路径。
- **禁止**把真实联调、真实网络、真实 RPA 写进复制后的默认用例;此类内容应放在 `tests/integration/*.sample` 并配合环境变量开关。
---
## 两类样例文件
### 1. `test_service_contract.py.sample`
**适合:**
- 技能有清晰的 **service 层入口**,例如 `run(...)``handle(...)``execute(...)`
- 需要验证:**成功路径**、**参数/校验错误**、**adapter 异常被转换为业务可读错误**。
- 需要用 **`FakeAdapter`**(见 `tests/adapter_test_utils.py`)或自建 **stub** 替代 ERP、TMS、WMS、船司、报关、LLM、RPA 等,**不**发真实 HTTP、**不**开真实浏览器。
### 2. `test_golden_cases.py.sample`
**适合:**
- **解析类**:邮件/单证/PDF/Excel 等结构化输出。
- **计算类**:费用、税费、分摊、账单字段。
- **校验类**:合规、字段一致性、状态机规则。
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
---
## 如何启用 service contract 样例
1. **复制文件**
`tests/samples/test_service_contract.py.sample`
`tests/test_service_contract.py`(或 `tests/test_<domain>_service_contract.py`,须符合根目录 `test_*.py` 命名以便 `run_tests.py` 收集)。
2. **替换示例 import**
把注释中的示意:
`# from service.your_service import run`
改为真实模块,例如:
`from service.quote_service import run_quote`
(具体路径以技能代码为准。)
3. **注入假外部依赖**
- **不要**在默认用例里直接 `requests` 访问 ERP/TMS/WMS 等。
- **不要**打开真实网页或驱动真实 RPA。
- **不要**调用真实 LLM可 stub 返回固定 JSON。
-**timeout / unauthorized / invalid_response** 等分支,用 `FakeAdapter` 或自建 fake 模拟(与 `adapter_test_utils` 思路一致)。
4. **至少保留三类断言习惯**
- **成功路径**:返回 `success: true` 或与技能约定的成功结构。
- **参数错误**:缺必填字段时返回**结构化** error字段/码),而非未捕获裸异常。
- **外部异常**adapter 超时、鉴权失败、脏响应等被转换为**可读、可重试标识明确**的错误形态(按产品约定)。
5. **给 AI 的提示**
复制后全文搜索 `your_service``TODO``伪代码` 等占位符并替换;不要留下指向真实客户的示例数据。
---
## 如何启用 golden case 样例
1. **复制文件**
`tests/samples/test_golden_cases.py.sample`
`tests/test_golden_cases.py`(或 `tests/test_<domain>_golden.py`)。
2. **按业务修改 fixtures**(仍须脱敏)
- `tests/fixtures/sample_request.json`:请求或输入快照。
- `tests/fixtures/expected_response.json`:期望摘要或关键字段。
可新增多组文件,如 `case_valid_001.json`,并在测试中显式读取路径。
3. **断言建议(避免脆弱用例)**
- 比较**关键字段**,避免对整段 JSON 做机械全文 diff。
- **忽略**时间戳、随机 id、`trace_id` 等非确定字段。
- 对列表:断言**长度**、**关键元素**、**排序规则**(若业务保证顺序)。
- 对金额/数量:断言 Decimal 字符串、或允许文档约定的小范围误差。
4. **与 service 的关系**
golden 用例仍应通过 **service 层**(或纯函数)得到输出,再与 fixture 对比,而不是从 CLI 字符串 scrape。
---
## 不应该写在 samples 复制件里的内容
以下若出现,应移到 **`tests/integration/*.sample`** 或 **`tests/desktop/*.sample`**,并加环境变量授权说明:
- 访问**真实** API 或内网服务。
- 打开**真实** RPA 页面或生产门户。
- 读取**真实**客户文件、生产数据库。
- 在测试中写入**真实**数据根或共享盘。
- 任何真实 **token / cookie / 密码** 字面量。
- 耗时极长、强依赖宿主 UI 抖动、无法在 CI 稳定复现的用例(不要塞进默认 `tests/test_*.py`)。
---
## 推荐命名(复制到根目录后)
| 用途 | 示例文件名 |
|------|------------|
| Service 契约 | `tests/test_service_contract.py` |
| Golden / fixture 回归 | `tests/test_golden_cases.py` |
| 领域规则 | `tests/test_<domain>_rules.py` |
| 校验逻辑 | `tests/test_<domain>_validation.py` |
命名需满足:`tests/` 根目录下 `test_*.py`,以便 `python tests/run_tests.py` 默认发现。
更多分层说明见 **`tests/README.md`**(「我该把测试写在哪里?」与「新技能最小测试清单」)。

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# 样例:复制为 ``tests/test_golden_cases.py`` 后运行;默认不收集。
"""
Golden fixture 回归(示意)。
- 从 ``tests/fixtures/`` 读取脱敏 JSON。
- 调用 service 层(复制后替换为真实 import
- 断言关键字段,而非全文 diff外部系统常带时间戳/非确定性字段)。
"""
from __future__ import annotations
import json
import os
import unittest
def _fixtures_dir() -> str:
return os.path.join(os.path.dirname(__file__), "..", "fixtures")
class TestGoldenCasesSample(unittest.TestCase):
def test_sample_request_matches_expected_shape(self) -> None:
req_path = os.path.join(_fixtures_dir(), "sample_request.json")
exp_path = os.path.join(_fixtures_dir(), "expected_response.json")
with open(req_path, encoding="utf-8") as f:
req = json.load(f)
with open(exp_path, encoding="utf-8") as f:
exp = json.load(f)
self.assertEqual(req["request_id"], "req_test_001")
self.assertTrue(req["parameters"]["options"]["dry_run"])
# 示意out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段
self.assertTrue(exp["success"])
self.assertEqual(exp["data"]["status"], "ok")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# 本文件为样例:复制到 ``tests/test_service_contract.py``(或业务仓 tests 根)后按实际 service 改名运行。
# 默认 **不会** 被 ``python tests/run_tests.py`` 收集(扩展名为 .sample
"""
Service 层契约测试范式(示意)。
复制为真实技能后:
1. 将 ``from service.your_service import run`` 替换为实际模块与入口函数。
2. 用 ``FakeAdapter`` / 内存 stub 替代真实 ERP/TMS/WMS 等外呼。
3. 需要真实外网或真实 RPA 时,勿在本文件直接写死凭证;改用 ``tests/integration/*.sample`` + 环境开关。
"""
from __future__ import annotations
import unittest
from adapter_test_utils import FakeAdapter
# from service.your_service import run # noqa: ERA001 — 复制后取消注释并实现
class TestServiceContractSample(unittest.TestCase):
"""三类常见契约:成功路径、结构化错误、上游超时转可重试错误。"""
def test_success_path_returns_success_true(self) -> None:
# 示意run(payload, adapter=FakeAdapter("success")) -> {"success": True, ...}
ad = FakeAdapter("success")
out = ad.call({"op": "ping"})
self.assertTrue(out.get("success"))
def test_missing_required_field_returns_structured_error_not_bare_exception(self) -> None:
# 示意:业务层应对必填字段做校验,返回 {"success": False, "error": {"code": "...", "message": "..."}}
err = {"success": False, "error": {"code": "VALIDATION", "message": "missing shipment_id"}}
self.assertFalse(err["success"])
self.assertIn("code", err["error"])
def test_adapter_timeout_maps_to_retriable_error(self) -> None:
# 示意:捕获 TimeoutError 后映射为业务可重试错误码,而不是让裸异常穿透 CLI
ad = FakeAdapter("timeout")
with self.assertRaises(TimeoutError):
ad.call({})
# 真实实现中except TimeoutError: return {"success": False, "error": {"code": "UPSTREAM_TIMEOUT", "retriable": True}}
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
"""adapter profile / 环境开关策略:默认不触碰真实 API 与真实 RPA。"""
from __future__ import annotations
import os
import unittest
from adapter_test_utils import (
ALLOWED_TEST_TARGETS,
FakeAdapter,
assert_no_real_network_env,
get_test_target,
should_skip_profile,
)
def _save_env(keys: tuple[str, ...]) -> dict[str, str | None]:
return {k: os.environ.get(k) for k in keys}
def _restore_env(saved: dict[str, str | None]) -> None:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
_ENV_KEYS = (
"OPENCLAW_TEST_TARGET",
"OPENCLOW_TEST_TARGET",
"ALLOW_REAL_API",
"ALLOW_REAL_RPA",
"ALLOW_WRITE_ACTIONS",
)
class TestAdapterProfilePolicy(unittest.TestCase):
def test_get_test_target_reads_opencylow_typo_alias(self) -> None:
"""兼容环境变量历史拼写 OPENCLOW_TEST_TARGET。"""
saved = _save_env(_ENV_KEYS)
try:
os.environ.pop("OPENCLAW_TEST_TARGET", None)
os.environ.pop("OPENCLOW_TEST_TARGET", None)
os.environ["OPENCLOW_TEST_TARGET"] = "mock"
self.assertEqual(get_test_target(), "mock")
finally:
_restore_env(saved)
def test_get_test_target_defaults_to_unit(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
self.assertEqual(get_test_target(), "unit")
finally:
_restore_env(saved)
def test_get_test_target_invalid_raises(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target"
with self.assertRaises(ValueError):
get_test_target()
finally:
_restore_env(saved)
def test_allowed_targets_contains_expected(self) -> None:
self.assertTrue({"unit", "mock", "simulator_api", "real_api"}.issubset(ALLOWED_TEST_TARGETS))
def test_simulator_api_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api"
skip, _ = should_skip_profile("simulator_api")
self.assertFalse(skip)
finally:
_restore_env(saved)
def test_unit_target_skips_simulator_and_real_profiles(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
self.assertEqual(get_test_target(), "unit")
for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
skip, reason = should_skip_profile(prof)
self.assertTrue(skip, msg=f"{prof}: {reason}")
skip_mock, _ = should_skip_profile("mock")
self.assertFalse(skip_mock)
finally:
_restore_env(saved)
def test_real_api_requires_allow_flag(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_api"
os.environ.pop("ALLOW_REAL_API", None)
skip, reason = should_skip_profile("real_api")
self.assertTrue(skip)
self.assertIn("ALLOW_REAL_API", reason)
os.environ["ALLOW_REAL_API"] = "1"
skip2, _ = should_skip_profile("real_api", write=False)
self.assertFalse(skip2)
finally:
_restore_env(saved)
def test_real_api_write_requires_allow_write_actions(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_api"
os.environ["ALLOW_REAL_API"] = "1"
os.environ.pop("ALLOW_WRITE_ACTIONS", None)
skip, reason = should_skip_profile("real_api", write=True)
self.assertTrue(skip)
self.assertIn("ALLOW_WRITE_ACTIONS", reason)
os.environ["ALLOW_WRITE_ACTIONS"] = "1"
skip2, _ = should_skip_profile("real_api", write=True)
self.assertFalse(skip2)
finally:
_restore_env(saved)
def test_real_rpa_requires_allow_rpa_flag(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_rpa"
os.environ.pop("ALLOW_REAL_RPA", None)
skip, reason = should_skip_profile("real_rpa")
self.assertTrue(skip)
self.assertIn("ALLOW_REAL_RPA", reason)
os.environ["ALLOW_REAL_RPA"] = "1"
skip2, _ = should_skip_profile("real_rpa", write=False)
self.assertFalse(skip2)
finally:
_restore_env(saved)
def test_fake_adapter_modes(self) -> None:
self.assertEqual(FakeAdapter("success").call({"a": 1})["success"], True)
with self.assertRaises(TimeoutError):
FakeAdapter("timeout").call({})
bad = FakeAdapter("invalid_response").call({})
self.assertNotIsInstance(bad, dict)
with self.assertRaises(PermissionError):
FakeAdapter("unauthorized").call({})
def test_assert_no_real_network_env(self) -> None:
saved = _save_env(("ALLOW_REAL_API", "ALLOW_REAL_RPA"))
try:
os.environ.pop("ALLOW_REAL_API", None)
os.environ.pop("ALLOW_REAL_RPA", None)
assert_no_real_network_env()
finally:
_restore_env(saved)
if __name__ == "__main__":
unittest.main()

75
tests/test_cli_smoke.py Normal file
View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""通用 CLI 冒烟用法、health、version、logs、log-get不触网、不深测 run 占位)。"""
from __future__ import annotations
import io
import json
import os
import unittest
from contextlib import redirect_stderr, redirect_stdout
from _support import IsolatedDataRoot
# scripts/ 已由 _support 注入 sys.path
from cli.app import main
from util.constants import SKILL_SLUG
class TestCliSmoke(unittest.TestCase):
def test_main_empty_argv_shows_usage_and_nonzero(self) -> None:
buf = io.StringIO()
err = io.StringIO()
with redirect_stdout(buf), redirect_stderr(err):
rc = main([])
self.assertEqual(rc, 1)
out = buf.getvalue()
self.assertIn("通用业务技能模板", out)
self.assertIn("health", out)
def test_health_zero(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["health"])
self.assertEqual(rc, 0)
def test_version_json_and_matches_constants_slug(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["version"])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue().strip())
self.assertIn("version", payload)
self.assertIn("skill", payload)
self.assertEqual(payload["skill"], SKILL_SLUG)
def test_logs_empty_returns_zero(self) -> None:
with IsolatedDataRoot():
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["logs"])
self.assertEqual(rc, 0)
self.assertIn("暂无", buf.getvalue())
def test_log_get_non_numeric_returns_nonzero(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["log-get", "not-a-number"])
self.assertNotEqual(rc, 0)
self.assertIn("数字", buf.getvalue())
def test_run_placeholder_returns_nonzero_without_network(self) -> None:
"""占位 run不验证业务成功仅确认模板提示且退出码非 0无 AUTH_BASE 时不发 HTTP"""
old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None)
try:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["run"])
self.assertNotEqual(rc, 0)
self.assertIn("模板", buf.getvalue())
finally:
if old_auth is not None:
os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth
if __name__ == "__main__":
unittest.main()

43
tests/test_db_smoke.py Normal file
View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""task_logs 表init 幂等、写入与查询(全程隔离数据目录)。"""
from __future__ import annotations
import unittest
from _support import IsolatedDataRoot
class TestDbSmoke(unittest.TestCase):
def test_init_db_idempotent_and_crud_smoke(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from db import task_logs_repository as tlr
init_db()
init_db() # 幂等
new_id = tlr.save_task_log(
task_type="demo_task",
target_id="tgt-1",
input_id="42",
input_title="t",
status="success",
error_msg=None,
result_summary=None,
)
self.assertIsInstance(new_id, int)
self.assertGreater(new_id, 0)
rows = tlr.list_task_logs(limit=10)
self.assertTrue(any(r[0] == new_id for r in rows))
row = tlr.get_task_log_by_id(new_id)
self.assertIsNotNone(row)
self.assertEqual(int(row[0]), new_id)
missing = tlr.get_task_log_by_id(999_999)
self.assertIsNone(missing)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""通过 subprocess 调用 ``python scripts/main.py``,验证真实入口(非仅 import main"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import unittest
from _support import IsolatedDataRoot, get_skill_root
def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]:
root = get_skill_root()
main_py = os.path.join(root, "scripts", "main.py")
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env["PYTHONDONTWRITEBYTECODE"] = "1"
return subprocess.run(
[sys.executable, main_py, *args],
cwd=root,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
class TestEntrypointSubprocess(unittest.TestCase):
def test_health_exit_zero(self) -> None:
with IsolatedDataRoot():
cp = _run_main(["health"])
self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout)
def test_version_json_and_skill_slug(self) -> None:
with IsolatedDataRoot():
cp = _run_main(["version"])
self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout)
payload = json.loads(cp.stdout.strip())
self.assertIn("version", payload)
self.assertIn("skill", payload)
from util.constants import SKILL_SLUG
self.assertEqual(payload["skill"], SKILL_SLUG)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""util.runtime_paths技能根、DB 路径、数据目录均落在隔离数据根下。"""
from __future__ import annotations
import os
import unittest
from _support import IsolatedDataRoot, get_skill_root
_FAKE_CLAW_ROOT = r"D:\jiangchang-data"
class TestRuntimePaths(unittest.TestCase):
def test_paths_under_isolated_root(self) -> None:
win_default = r"D:\jiangchang-data"
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
skill_root = rp.get_skill_root()
self.assertTrue(os.path.isdir(skill_root))
self.assertTrue(os.path.isfile(os.path.join(skill_root, "SKILL.md")))
db_path = rp.get_db_path()
self.assertTrue(os.path.normpath(db_path).startswith(os.path.normpath(tmp)))
self.assertNotIn(win_default, db_path)
data_dir = rp.get_skill_data_dir()
self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp)))
self.assertIn("_test", data_dir.replace("\\", "/"))
self.assertNotIn(win_default, data_dir)
self.assertEqual(get_skill_root(), skill_root)
def test_isolation_overrides_prefixed_claw_data_root(self) -> None:
"""先设假的 CLAW_DATA_ROOT再进入隔离get_db_path 必须落在 tempdir防漏设 CLAW_*)。"""
saved = {k: os.environ.get(k) for k in ("CLAW_DATA_ROOT", "JIANGCHANG_DATA_ROOT", "CLAW_USER_ID", "JIANGCHANG_USER_ID")}
try:
os.environ["CLAW_DATA_ROOT"] = _FAKE_CLAW_ROOT
os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_CLAW_ROOT
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
db_path = rp.get_db_path()
norm_db = os.path.normcase(os.path.normpath(db_path))
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(norm_db.startswith(norm_tmp), msg=f"db_path={db_path!r} tmp={tmp!r}")
norm_fake = os.path.normcase(os.path.normpath(_FAKE_CLAW_ROOT))
self.assertFalse(norm_db.startswith(norm_fake), msg=f"db_path must not be under fake CLAW root: {db_path!r}")
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""SKILL.md 与 util.constants.SKILL_SLUG 一致性(防复制后漏改 constants"""
from __future__ import annotations
import os
import re
import unittest
from _support import get_skill_root
def _parse_openclaw_slug(skill_md_text: str) -> str:
"""从 YAML frontmatter 中解析 metadata.openclaw.slug无 PyYAML 依赖)。"""
parts = skill_md_text.split("---", 2)
if len(parts) < 2:
raise ValueError("missing frontmatter")
fm = parts[1]
lines = fm.splitlines()
for i, line in enumerate(lines):
if re.match(r"^\s+openclaw:\s*$", line):
for j in range(i + 1, len(lines)):
inner = lines[j]
if inner.strip() and inner[0] not in (" ", "\t"):
break
m = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
if m:
return m.group(2).strip().strip("\"'")
break
raise ValueError("slug not found under openclaw")
class TestSkillMetadata(unittest.TestCase):
def test_skill_slug_alignment(self) -> None:
root = get_skill_root()
skill_dir = os.path.basename(root)
md_path = os.path.join(root, "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
slug_md = _parse_openclaw_slug(md)
from util.constants import SKILL_SLUG
self.assertEqual(slug_md, SKILL_SLUG, "SKILL.md slug 必须与 constants.SKILL_SLUG 一致")
if skill_dir == "skill-template":
# 模板仓库本体:允许占位 slug
self.assertEqual(SKILL_SLUG, "your-skill-slug")
else:
self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug")
self.assertEqual(SKILL_SLUG, skill_dir)
self.assertEqual(slug_md, skill_dir)
if __name__ == "__main__":
unittest.main()