Compare commits

...

7 Commits

Author SHA1 Message Date
48a86e56f1 chore: auto release commit (2026-07-03 18:50:07)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-03 18:50:08 +08:00
7136689efe feat: 固化 verb-noun-platform slug 命名规范与语义校验
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-03 10:24:05 +08:00
e366605299 feat: 规范标准时间字段 created_at/updated_at(Unix 秒级、元数据、trigger)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-01 18:51:33 +08:00
707d97760f feat: 匠厂数据管理展示元数据规范——元数据表初始化/校验、display_order 契约、文档与测试
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-01 11:39:28 +08:00
d17804e45b 同步 Platform Kit 1.0.17 运行时环境变量契约
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-06-30 14:41:54 +08:00
a3bd8faf87 docs(config): unify config.get reads and remove ALLOW_* gates
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-30 10:29:03 +08:00
30803a0834 chore: auto release commit (2026-06-21 10:51:04)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-21 10:51:05 +08:00
65 changed files with 4191 additions and 843 deletions

View File

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

5
.openclaw-skill-template Normal file
View File

@@ -0,0 +1,5 @@
# 此文件仅存在于 skill-template 源仓库。
# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。
# 若业务技能仓库中仍存在本文件,说明未正确 scaffoldtests 会失败。
template_slug: your-skill-slug
template_version: "1.0.32"

View File

@@ -1,12 +1,12 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.14
version: 1.0.32
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: your-skill-slug
platform_kit_min_version: "1.0.14"
platform_kit_min_version: "1.0.17"
emoji: "📦"
category: "通用"
developer_ids:
@@ -38,6 +38,7 @@ allowed-tools:
- **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/``development/`
-`README.md``SKILL.md` / `references` 表述冲突:对用户展示与市场说明以 `README.md` 为准对执行契约、CLI、schema、运行约束以 `SKILL.md` / `references` 为准。
- 回答用户时**不要**暴露 Playwright、DOM、Python、RPA 等实现细节,除非用户明确询问技术实现。
- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`
## 文档分工
@@ -64,24 +65,36 @@ python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py version
```
配置:仓库 `.env.example` 为模板;用户 `.env``{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级进程环境变量 > 用户 `.env` > `.env.example`
配置:仓库 `.env.example` 为模板;用户 `.env``{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级进程环境变量 > 用户 `.env` > `.env.example`
## 运行依赖
- Python 运行环境由匠厂宿主注入**共享 runtime**`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.14``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本。
- 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.17``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本。
- config、logging、runtime_env、rpa、media-assets、video_session、runtime_diagnostics 等均从共享 venv 的 `jiangchang_skill_core` import而非技能目录副本。
- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.14`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。
- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装。
- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets
## 定制入口
1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词
2. `development/DEVELOPMENT.md` 完成开发与文档定制
3. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md`
4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md)
2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)
3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词
4. `development/DEVELOPMENT.md` 完成开发与文档定制
5. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md`
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。
## 技能命名与 slug
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case`name``description`、根 `README.md`**中文**。
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`
- `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。
- `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。
- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。
## 平台元数据

View File

@@ -0,0 +1,13 @@
amazon
shopee
xinghang
alibaba
kingdee
icbc
pingpong
worldfirst
paypal
lianlian
single-window
export-rebate
etax

4
assets/naming/scopes.txt Normal file
View File

@@ -0,0 +1,4 @@
all
batch
multi
default

25
assets/naming/verbs.txt Normal file
View File

@@ -0,0 +1,25 @@
scrape
receive
download
export
add
fill
enter
bind
withdraw
reconcile
verify
submit
query
process
reply
generate
draft
review
confirm
track
upload
match
triage
ship
reprice

View File

@@ -17,8 +17,8 @@
|------|------|----------|
| **`mock`** | 纯内存或 fixture**默认单测/CI** | 模板 `.env.example` 默认 `OPENCLAW_TEST_TARGET=mock` |
| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 开发联调可选 |
| **`real_api`** | 真实 API | **必须** `ALLOW_REAL_API=1` |
| **`real_rpa`** | 真实浏览器/真实系统 | **必须** `ALLOW_REAL_RPA=1` |
| **`real_api`** | 真实 API | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_api` |
| **`real_rpa`** | 真实浏览器/真实系统 | 生产 / 集成测试显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
- **mock**:纯离线、不联网,给单测 / CI / 开发自测,**保证可重复**。
- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),跑端到端流程但不碰生产。
@@ -27,17 +27,7 @@
> 推荐优先级:**real_api > simulator_rpa > real_rpa**mock 永远保留做 CI。
## 权限开关
未显式授权时**不得**落到真实 API/RPA
| 变量 | 作用 |
|------|------|
| `ALLOW_REAL_API=1` | 允许 `real_api` 访问真实 HTTP |
| `ALLOW_REAL_RPA=1` | 允许 `real_rpa` 驱动真实浏览器/RPA |
| `ALLOW_WRITE_ACTIONS=1` | 在 `real_*` 下允许**写**操作(提交表单、下单等) |
进程环境变量优先于用户 `.env`(见 `CONFIG.md` 三层优先级)。
配置读取见 `CONFIG.md`**bootstrap 之后业务代码只通过 `config.get*()``OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。
## 目录骨架
@@ -47,17 +37,22 @@ scripts/service/<domain>_adapter/
base.py # 数据契约dataclass+ AdapterBase 接口
mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPA
sim_rpa.py # 仿真平台 RPA adapter委托 *_playwright.py
real_rpa.py # 真实系统 RPA占位谨慎实现
scripts/service/
<platform>_playwright.py # RPA 主流程(厚);与薄 sim_rpa.py 并列
account_client.py # account-manager subprocess 唯一封装
browser_session.py # async persistent context 启动
```
模板示例(均在 `examples/`**不在** `scripts/`
- **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` — mock + simulator_rpa + sandbox 页面 + adapter 分层 + 测试
- **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集
- **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` async mock + simulator_rpa + `simulator_playwright.py` + sandbox 页面 + adapter 分层 + 测试
- **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集 + `account_client.py`
- **真实 API / 仿真 API**`examples/real_api/``examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现
复制 adapter 分层时,以 `examples/simulator_browser_rpa/scripts/service/adapter/` 为权威参考,在新技能中创建 `scripts/service/<domain>_adapter/` 并按 README copy map 选择性复制。
复制 adapter 分层时,以修正后的 `examples/simulator_browser_rpa/`(薄 adapter + `simulator_playwright.py`)与 `examples/real_browser_rpa/scripts/service/account_client.py` 为权威参考,在新技能中创建 `scripts/service/<domain>_adapter/` 并按 README copy map 选择性复制。
> 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。
@@ -74,8 +69,10 @@ scripts/service/<domain>_adapter/
```python
# __init__.py
from jiangchang_skill_core import config
def get_adapter():
target = (os.environ.get("OPENCLAW_TEST_TARGET") or "mock").lower()
target = (config.get("OPENCLAW_TEST_TARGET") or "mock").lower()
if target in ("unit", "mock"):
return MockAdapter()
if target == "real_api":
@@ -85,7 +82,7 @@ def get_adapter():
return SimRpaAdapter()
```
`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**未授权时不 silently 开启真实网络/RPA**。
`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**默认必跑测试不得误设 `OPENCLAW_TEST_TARGET=real_*`**。
## contract tests
@@ -101,7 +98,7 @@ def get_adapter():
1.`SKILL.md``metadata.openclaw.dependencies.required` 声明。
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 `task_service.py``task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa``real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。

View File

@@ -7,7 +7,7 @@
1. **三层优先级**`进程环境变量` > `{数据目录}/.env` > 仓库 `.env.example` 默认值。
2. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。
3. **首次运行自动 copybootstrap**`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到
`{CLAW_DATA_ROOT}/{user}/{slug}/.env`**已存在则不整体覆盖**,保护用户改过的值)。
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`**已存在则不整体覆盖**,保护用户改过的值)。
4. **技能升级合并缺失项**`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带注释块)。
5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。
@@ -23,6 +23,7 @@ OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_
TARGET_BASE_URL=https://sandbox.jc2009.com
# ── 默认账号(仅非敏感标识;密码走 account-manager──
# 浏览器 RPA simulator_rpa 联调账号来自 account-managerplatform + profile_dir不是此处的 DEFAULT_LOGIN_ID
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ──
@@ -96,4 +97,4 @@ config.get_float("STEP_DELAY_MIN", 1.0)
- `RPA.md` — 三端 RPA 标准与各开关含义
- `ADAPTER.md``OPENCLAW_TEST_TARGET` 四档模式
- `RUNTIME.md``CLAW_*` 环境变量与数据目录约定
- `RUNTIME.md``JIANGCHANG_*` 环境变量与数据目录约定

View File

@@ -79,11 +79,14 @@
你不应该直接在这个仓库里开发业务,而应该:
1. 复制这个目录
2. 改成新 skill 的目录名
0. 按 [`NAMING.md`](NAMING.md) 确定 slug`{verb}-{noun-phrase}-{platform}`
1. **优先**用 [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1) 创建新目录(见 [`tools/README.md`](../tools/README.md)
2. 在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`
3. 把占位内容替换掉
4. 再开始写业务逻辑
> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git``git remote -v` 必须指向新技能仓库,不能仍是 skill-template。
## 2. 新 skill 的标准目录结构
复制模板后,你应该保留下面这套结构:
@@ -153,7 +156,7 @@ scripts/
- `util/`
作用:常量、日志、路径、时间工具、通用帮助函数
公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics从共享 runtime 的 `jiangchang-platform-kit>=1.0.14` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
公共能力config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics从共享 runtime 的 `jiangchang-platform-kit>=1.0.17` import**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 3.2 开发 RPA 类 skill
@@ -167,10 +170,13 @@ scripts/
- `examples/real_browser_rpa/scripts/service/task_rpa.py`
- `examples/real_browser_rpa/scripts/service/account_client.py`
- 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/`
- `examples/simulator_browser_rpa/scripts/service/browser_session.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`
- `examples/simulator_browser_rpa/scripts/service/task_service.py`
- `examples/simulator_browser_rpa/scripts/service/browser_session.py`async
- `examples/simulator_browser_rpa/scripts/service/account_client.py`subprocess对齐 `real_browser_rpa`
- `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py`RPA 主流程,复制后改名为 `<platform>_playwright.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock
- `examples/simulator_browser_rpa/scripts/service/task_service.py`async 编排)
- `sandbox/demo_app.html` 仅留在 examples不进入生产 skill
- **禁止**`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
3. **只用共享库,不在 skill 里重写反反爬**
```python
from jiangchang_skill_core import config
@@ -187,7 +193,7 @@ scripts/
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.0.14`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
@@ -243,12 +249,52 @@ release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前
### 第一步:复制模板并改目录名
例如你要开发 `your-skill-slug`(或 `disburse-payroll-icbc` 一类领域 skill
例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug
1. 复制 `skill-template`
2. 新目录改成与 `slug` 一致的名称(如 `your-skill-slug`
3. 初始化为独立 git 仓库
4. 关联它自己的远端仓库
#### 推荐方式(首选)
在 **skill-template 仓库根目录**执行:
```powershell
.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc
cd D:\OpenClaw\client-gdcm\disburse-payroll-icbc
git init
git remote add origin <你的新技能 Gitea/Git 仓库 URL>
git remote -v # 确认 origin 不是 skill-template
```
跨平台可用 `python tools/scaffold_skill.py --slug ... --destination ...`(见 [`tools/README.md`](../tools/README.md))。
脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。
#### 禁止方式
| 做法 | 后果 |
|------|------|
| ❌ 资源管理器整文件夹复制后不做 Git 清理 | 隐藏 `.git` 被带走push 串到 template |
| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template |
| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 |
#### 若已手工复制(补救)
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
#### AI / 编程代理复制红线
| 禁止 | 说明 |
|------|------|
| 保留模板 `.git` | 必须先 `Remove-Item -Recurse -Force .git` 再 `git init` |
| `origin` 仍指向 skill-template | 复制后必须 `git remote -v` 自检 |
| 保留 `.openclaw-skill-template` | 仅 template 源仓库可有;业务技能不得保留 |
| 复制 `.pytest_cache` / `__pycache__` | 用 scaffold 已排除;手工复制应删 |
目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。
@@ -445,6 +491,10 @@ metadata:
- `db/task_logs_repository.py`
只做增删查改(模板默认表:`task_logs`
- `db/display_metadata.py`
创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`
- `db/timestamp_columns.py`
标准时间字段 trigger 与维护约定(`created_at` / `updated_at`Unix 秒级)
不要在 `db` 层里:
@@ -555,7 +605,7 @@ python scripts/main.py <your-command>
`.github/workflows/release_skill.yaml` 默认引用:
```yaml
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
```
约定如下:
@@ -678,6 +728,8 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam
每个新 skill 发布前,建议技术人员逐条确认:
- [ ] 目录结构符合当前模板
- [ ] slug 符合 [`NAMING.md`](NAMING.md)verb-noun-platform
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `scripts/util/constants.py` 已修改
- [ ] `../references/CLI.md` 示例命令已改成真实命令
@@ -692,6 +744,9 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam
- [ ] 源码/文档/配置为 **UTF-8 without BOM**Python 文件不得含 `U+FEFF`
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`
- [ ] `git remote -v` 指向**本技能**远端URL 不含 skill-template 仓库名
- [ ] `git log` 首条提交属于本技能(非模板历史)
## 17. 常见错误
@@ -747,11 +802,65 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam
- 用户说明在根 `README.md`Agent 资料在 `references/`,开发规范在 `development/`
- 统一走 `scripts/main.py` 作为 CLI 入口
### 错误 5平台放 slug 最前
表现:
- slug 形如 `erp-download-settlement-report`(平台段错误地放在最前)
- 语义校验或 `test_slug_naming` 失败
要改:
- 动词放首段,平台放末段,例如 `download-settlement-report-erp`
- 见 [`NAMING.md`](NAMING.md)
### 错误 6noun 段过长导致超 48 字符
表现:
- slug 超过 48 字符
- 中间名词段堆砌过多
要改:
- 精简 noun-phrase保留核心业务含义
- 见 [`NAMING.md`](NAMING.md) §3 硬约束
### 错误 7只改目录名未改 slug
表现:
- 与「错误 1」相同但目录已改名而 `SKILL.md` / `constants.py` 仍为占位
要检查:
- `SKILL.md` 的 `metadata.openclaw.slug`
- `scripts/util/constants.py` 的 `SKILL_SLUG`
### 错误 8复制模板时带走了 `.git`
表现:
- `git remote -v` 仍显示 skill-template 的 origin
- push 到错误仓库,或 log 里全是模板历史
- 新技能目录里仍有 `.openclaw-skill-template`
修复:
```powershell
Remove-Item -Recurse -Force .git
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
```
以后复制请优先用 `tools/scaffold_skill.ps1`(见 [`tools/README.md`](../tools/README.md))。
## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序:
1. 复制模板并改目录名
1. 按 [`NAMING.md`](NAMING.md) 确定 slug复制模板并改目录名
2. 改 `SKILL.md` 与根 `README.md`
3. 改 `scripts/util/constants.py`
4. 改 `references/` 与 `development/`

237
development/NAMING.md Normal file
View File

@@ -0,0 +1,237 @@
# 技能 slug 命名规范verb-noun-platform
> 本文是匠厂**新业务 skill** 的 slug / Git 仓库名 / 目录名命名权威说明。复制 `skill-template` 出去开发新技能前**必读**Agent 在开发者询问命名或 slug 时也应先读本文。
---
## 1. 适用范围
| 范围 | 说明 |
|------|------|
| ✅ **适用** | 从 `skill-template` **复制出去的新技能** |
| ❌ **不追溯** | 已上线历史技能(如 `account-manager``reconcile-finance``scrape-contacts-lianjia` 等)保持不动 |
| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 |
| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 |
**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。
---
## 2. 命名格式
标准形态(约 95% 新技能):
```text
{verb}-{noun-phrase}-{platform}
```
| 部分 | 规则 |
|------|------|
| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) |
| **noun-phrase** | 13 个词(按 `-` 分段),描述业务对象 |
| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window` |
**展示层分工不变:**
| 字段 | 语言 | 示例 |
|------|------|------|
| slug / repo / 目录 / `SKILL_SLUG` / DB 文件名 | **英文** kebab-case | `download-settlement-report-amazon` |
| `SKILL.md``name``description`、根 `README.md` | **中文** | `亚马逊结算报表下载` |
---
## 3. 硬约束(新技能强制校验)
| 约束 | 值 |
|------|-----|
| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) |
| 总段数(按 `-` 分割) | **35 段**标准型scope 型见 [§4](#4-三类合法形态) |
| 总长度 | **≤ 48 字符** |
| 字符集 | 小写字母、数字、连字符kebab-case |
| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx` |
实现校验见 `scripts/util/slug_naming.py``scripts/db/display_metadata_validator.py``SKILL.md` frontmatter 自检时调用。
---
## 4. 三类合法形态
### 4.1 标准型95%
`verb-noun-platform``verb-noun-noun-platform`35 段)
- 第 1 段 ∈ 动词白名单
- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单
- 中间为 noun-phrase13 段)
示例:
- `download-settlement-report-amazon`
- `add-product-shopee`
- `enter-sales-receipt-kingdee`
- `fill-export-declaration-single-window`
### 4.2 scope 型(少见)
`verb-noun-scope`
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default`
- 第 1 段 ∈ 动词白名单
- 总段数 35
示例:`generate-article-all`
> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。
### 4.3 历史豁免(不校验语义)
以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`
- `account-manager`
- `skill-template`
- `your-skill-slug` / `your_skill_slug`(模板占位)
---
## 5. 动词白名单
词表文件:[`assets/naming/verbs.txt`](../assets/naming/verbs.txt)(每行一词,便于后续扩展)
| 动词 | 动词 | 动词 | 动词 |
|------|------|------|------|
| scrape | receive | download | export |
| add | fill | enter | bind |
| withdraw | reconcile | verify | submit |
| query | process | reply | generate |
| draft | review | confirm | track |
| upload | match | triage | ship |
| reprice | | | |
---
## 6. 平台白名单
词表文件:[`assets/naming/platforms.txt`](../assets/naming/platforms.txt)
| 平台 | 平台 | 平台 |
|------|------|------|
| amazon | shopee | xinghang |
| alibaba | kingdee | icbc |
| pingpong | worldfirst | paypal |
| lianlian | single-window | export-rebate |
| etax | | |
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all``batch``multi``default`
---
## 7. slug 与展示字段分工
```yaml
---
name: 亚马逊结算报表下载 # 中文,用户可见
description: "从卖家中心下载结算报表并入库" # 中文说明
metadata:
openclaw:
slug: download-settlement-report-amazon # 英文机器标识
---
```
同步修改:
- 目录名
- `metadata.openclaw.slug`
- `scripts/util/constants.py` 中的 `SKILL_SLUG`
- 默认数据库路径 `{slug}/{slug}.db`
三者必须一致。
---
## 8. 与 account-manager 的关系
slug **末段 platform**`account-manager` 中的 `platform_key` **语义对应**,但**不要求字符串完全相等**。
| slug 末段 (platform) | account-manager platform_key示例 |
|----------------------|--------------------------------------|
| amazon | `amazon_sim` / `amazon` |
| shopee | `shopee` |
| kingdee | `kingdee` |
新技能若需账号下发,在 `REQUIREMENTS.md` §0 填写拟用的 `platform_key`,并与兄弟技能调用参数对齐。
---
## 9. 复制新技能流程
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
2. **复制目录****推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)`-Slug` 与目录名一致);勿整包复制带走 `.git`
2.5. **清理 Git若未用 scaffold** — 删除 `.git``.openclaw-skill-template``.pytest_cache`;再 `git init``git remote add origin`
3. **全局替换**`your-skill-slug` → 新 slug更新 `SKILL.md``constants.py`、文档中的占位
4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识
5. **开发 & 自检**`python tests/run_tests.py -v`,含 slug 语义测试;`git remote -v` 确认非 template
---
## 10. 正例 / 反例
### ✅ 正例
| slug | 说明 |
|------|------|
| `download-settlement-report-amazon` | 标准型4 段 |
| `add-product-shopee` | 标准型3 段 |
| `enter-sales-receipt-kingdee` | 标准型 |
| `fill-export-declaration-single-window` | 标准型,平台含连字符 |
| `generate-article-all` | scope 型 |
### ❌ 反例
| slug | 原因 |
|------|------|
| `amazon-download-settlement-report` | 平台在最前 |
| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 |
| `reconcile-finance` | 新技能禁止 2 段无平台 |
| `download_settlement_amazon` | 非 kebab-case下划线 |
---
## 11. 反模式
- **平台前置**`amazon-download-...``shopee-add-...`
- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称
- **只改目录名**`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步
- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读
- **仿历史 2 段 repo**`reconcile-finance``content-manager`
---
## 12. 演进说明(当前不启用)
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。
---
## 13. 历史形态说明
以下为代表性**历史 repo**,新技能**勿仿**
| 历史 slug | 说明 |
|-----------|------|
| `account-manager` | 跨平台账号管理,非 verb-noun-platform |
| `reconcile-finance` | 2 段无 platform |
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) |
| `skill-template` | 模板仓库本身 |
校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。
---
## 14. 相关文档
| 文档 | 内容 |
|------|------|
| [`DEVELOPMENT.md`](DEVELOPMENT.md) | 复制模板步骤、发布前检查清单 |
| [`REQUIREMENTS.md`](REQUIREMENTS.md) | §0 技能标识必填项 |
| [`../references/SCHEMA.md`](../references/SCHEMA.md) | kebab-case 与数据库路径 |
| [`../SKILL.md`](../SKILL.md) | frontmatter 命名摘要 |

View File

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

View File

@@ -15,6 +15,14 @@
# REQUIREMENTS
## 0. 技能标识(复制 REQUIREMENTS 时必填)
- 拟定 slug英文符合 `development/NAMING.md`
- 中文 name
- 中文 description一句话
- account-manager platform_key如有
- 命名形态:标准型 / scope 型
## 1. 文档目标
说明这份需求文档的作用,以及它解决什么问题。
@@ -149,7 +157,10 @@
- `health``version` 命令执行正常
- 主命令(如 `run`)在 mock / simulator 档位可重复验证
- `python tests/run_tests.py -v` 必跑测试全部通过
- `task_logs` 写入和查询符合 `references/SCHEMA.md`
- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用

View File

@@ -51,7 +51,7 @@
6. **可以** `ignore_default_args=["--enable-automation"]`platform-kit `launch_persistent_browser` 已处理)。
7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、持久 profile**不要**直接拼接搜索结果 URL 或 DOM 注入。
指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.0.14**)。
指纹淡化stealth典型项`navigator.webdriver=undefined``chrome.runtime``permissions.query``plugins``languages` 等。共享实现见 `jiangchang_skill_core.rpa`platform-kit **>= 1.0.17**)。
**拟人操作**(必做):
@@ -124,7 +124,7 @@ from jiangchang_skill_core.rpa import (
from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT
```
- `RpaVideoSession` 来自 platform-kit **>= 1.0.14**ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。
- `RpaVideoSession` 来自 platform-kit **>= 1.0.17**ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。
- `health` 对上述资源做只读诊断,不下载、不修复。
### 1.5 真实浏览器 RPA 示例(必读)
@@ -138,7 +138,7 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
- `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程
- `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装
仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/scripts/service/adapter/`**不能**替代真实浏览器 RPA 规范。
仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/`(含 async `simulator_playwright.py`、薄 `simulator_rpa` adapter、`account_client.py` subprocess**不能**替代真实浏览器 RPA 规范。
### 1.6 仿真浏览器 RPA 示例(必读)
@@ -146,12 +146,32 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
1. **必须先阅读** `examples/simulator_browser_rpa/README.md`
2. 再参考:
- `examples/simulator_browser_rpa/scripts/service/browser_session.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`base / mock / simulator_rpa / dispatch
- `examples/simulator_browser_rpa/scripts/service/task_service.py`
- `examples/simulator_browser_rpa/scripts/service/browser_session.py` — async persistent context**不含 goto**
- `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py` — RPA 主流程(厚
- `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock + dispatch
- `examples/simulator_browser_rpa/scripts/service/account_client.py` — account-manager subprocess
- `examples/simulator_browser_rpa/scripts/service/task_service.py` — async 编排
- `examples/simulator_browser_rpa/sandbox/demo_app.html`
> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。
> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox / 行业仿真 → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。
### 1.7 行业仿真平台浏览器 RPAjc2009 类)
适用:**自有或共享 `sandbox.jc2009.com` 等行业仿真平台**,不是真实高风控第三方站。
工程范式与 §1.5 相同:
| 项 | 标准 |
|----|------|
| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 |
| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`+ `account_client.py` subprocess |
| 登录 | **双层**:门户 HITL`#portal-user` / `#portal-pass` 类泛化 DOM+ 业务系统登录(技能内自写) |
| 账号 | `url` 用行业根,不用 `/login``auth_strategy=per_session_manual``pick_web_account` + `release_lease` |
| 禁止 | **不要** `import account-manager``rpa_helpers` 等内部模块 |
| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback共享 sandbox **不要求**为技能加 testid |
| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 |
权威 example**修正后的** `examples/simulator_browser_rpa/`(不是任何生产技能仓库)。
---
@@ -234,7 +254,7 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
### 5.1 截图存证
- **路径**`{CLAW_DATA_ROOT}/{user}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **路径**`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png`
- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。
- **Playwright 不负责录屏**,仅浏览器自动化。
- **常见 tag**`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`

View File

@@ -2,7 +2,7 @@
## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime`jiangchang-platform-kit>=1.0.14` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored应由共享 venv 的 site-packages 提供。
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime`jiangchang-platform-kit>=1.0.17` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored应由共享 venv 的 site-packages 提供。
技能根目录 `requirements.txt` **只声明技能特有依赖****不要**重复声明 `jiangchang-platform-kit``playwright``SKILL.md``platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
@@ -22,15 +22,15 @@ Windows:
<shared-python> {baseDir}/scripts/main.py health
```
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入;开发模式下也可能通过 `CLAW_DATA_ROOT` 解析(见 `jiangchang_skill_core.runtime_env`)。
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入;开发模式下也可能通过 `JIANGCHANG_DATA_ROOT` 解析(见 `jiangchang_skill_core.runtime_env`)。
## Runtime 诊断platform-kit 1.0.14+
## Runtime 诊断platform-kit 1.0.17+
`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段:
- `skill_slug``python_executable``platform_kit_version``platform_kit_min_version`
- `jiangchang_skill_core_file` — 若从技能目录加载会输出 `runtime_issue[warning]`
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` / `resolved_data_root`
- `JIANGCHANG_DATA_ROOT` / `resolved_data_root`
- `media_assets_root``ffmpeg_path``background_music_*`
- `record_video_enabled``runtime_issue[warning|error]`
@@ -39,10 +39,10 @@ Windows:
## 配置 bootstrap
- 仓库内 `.env.example` 是配置模板(单一事实来源)。
- 用户实际 `.env``{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/.env`
- 用户实际 `.env``{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`
- `scripts/main.py``cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`
- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。
- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.0.14`**不得** vendored `scripts/jiangchang_skill_core/`
- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.0.17`**不得** vendored `scripts/jiangchang_skill_core/`
## media-assets / ffmpeg / 背景音乐
@@ -52,7 +52,7 @@ Windows:
{JIANGCHANG_DATA_ROOT}/shared/media-assets
```
`CLAW_DATA_ROOT` 解析规则一致,由宿主注入。)
(由宿主注入 `JIANGCHANG_DATA_ROOT`。)
RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均由 platform-kit 提供;技能只 import不重复实现。
@@ -71,7 +71,7 @@ RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均
- `scripts/main.py`:唯一 CLI 入口
- `scripts/cli/`:参数解析与命令分发
- `scripts/db/`SQLite 或本地持久化层
- `scripts/db/`SQLite 或本地持久化层(标准时间字段见 `references/SCHEMA.md`
- `scripts/service/`:业务用例与外部交互
- `scripts/util/`:通用工具、常量、日志、路径薄封装
@@ -80,7 +80,7 @@ RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均
推荐:
```text
{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
```
数据库文件建议:
@@ -89,6 +89,9 @@ RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均
{...}/{skill_slug}.db
```
- 业务表使用英文 snake_case中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。
## 明确禁止
- **不得** vendored `scripts/jiangchang_skill_core/`
@@ -97,12 +100,12 @@ RPA 录屏成片(`RpaVideoSession`、ffmpeg 路径、背景音乐探测均
## 测试时的运行时隔离
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间同时设置:
本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间设置:
- `CLAW_DATA_ROOT` / `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `CLAW_USER_ID` / `JIANGCHANG_USER_ID``_test`
- `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录
- `JIANGCHANG_USER_ID``_test`
退出时个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
退出时个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。
详见 `TESTING.md` 第 3 节。

View File

@@ -23,13 +23,16 @@
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`runtime diagnostics/ `version` / `logs` / `log-get` 冒烟;
- 架构守护:无 `scripts/jiangchang_skill_core/``platform-kit>=1.0.14` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- 架构守护:无 `scripts/jiangchang_skill_core/``platform-kit>=1.0.17` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- **真实 subprocess**[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths`**`CLAW_*` / `JIANGCHANG_*` 并发兜底
- 运行时:`runtime_paths`**`JIANGCHANG_*` 隔离**
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序(见 `test_display_metadata.py`
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`
- **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_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
- **slug 语义**[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration并由档位变量放行
@@ -39,7 +42,7 @@
## 3. 数据隔离IsolatedDataRoot
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,**镜像写入四套变量**`CLAW_DATA_ROOT``JIANGCHANG_DATA_ROOT`同一 tempfile、以及用户镜像 ID`CLAW_USER_ID``JIANGCHANG_USER_ID``_test`
[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,写入 `JIANGCHANG_DATA_ROOT`tempfile`JIANGCHANG_USER_ID``_test`
结束时:**恢复原 environ**,删除目录。
@@ -71,22 +74,14 @@ def test_whatever():
| `mock` | 与 `unit` 类似的安全档位(显式语义) |
| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** |
| `real_api` | 真实 API另需 `ALLOW_REAL_API=1` |
| `real_rpa` | 真实 RPA另需 `ALLOW_REAL_RPA=1` |
| `real_api` | 真实 API显式设 `OPENCLAW_TEST_TARGET=real_api` |
| `real_rpa` | 真实 RPA显式设 `OPENCLAW_TEST_TARGET=real_rpa` |
未设置环境变量 ⇒ 等价 `unit`
授权开关(显式 `1`)语义 **`ALLOW_REAL_API` / `ALLOW_REAL_RPA` / `ALLOW_WRITE_ACTIONS`**——摘录 [`tests/README.md`](../tests/README.md) §5.2
默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**。
| 变量 | 作用 |
|------|------|
| `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`(历史拼写)。
档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。
---
@@ -135,6 +130,20 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
---
## 7.1 simulator_rpa 联调检查表
浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认:
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过mock 离线example 内 `pytest` 不启真实浏览器
参考实现:`examples/simulator_browser_rpa/README.md`
---
## 8. 新 skill 的最小测试清单
以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选):
@@ -164,7 +173,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref |
| mock 优先 | 逻辑应在 service + FakeAdapter而不是巨胖 CLI 断言 |
| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 |
| **integration = 显式开关** | `OPENCLAW_TEST_TARGET` + `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` |
| **integration = 显式档位** | `OPENCLAW_TEST_TARGET``real_*` 只放 integration / 手动触发) |
---
@@ -219,7 +228,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ]`scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.0.14**`SKILL.md` + `constants.py`
- [ ] `platform_kit_min_version` **>= 1.0.17**`SKILL.md` + `constants.py`
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`

View File

@@ -19,13 +19,23 @@
## 两类浏览器 RPA 的区别
| | real_browser_rpa | simulator_browser_rpa |
| | real_browser_rpa | simulator_browser_rpa(修正后) |
|---|---|---|
| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox |
| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox / jc2009 类 |
| 风控 | 高(验证码、登录态、反爬) | 低DOM 可控) |
| 账号 | 通常需 account-manager + profile | 示例可用 env / demo 账号 |
| 账号 | account-manager + profile | account-manager + profile`simulator_rpa` 档) |
| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 |
### 工程范式对比
| 工程范式 | real_browser_rpa | simulator_browser_rpa修正后 |
|---|---|---|
| async Playwright | ✅ | ✅ |
| 薄 adapter + `*_playwright.py` | `task_rpa.py` | `simulator_playwright.py` |
| account-manager subprocess | ✅ | ✅(`simulator_rpa` 档位) |
两者工程范式一致,差异在目标系统(真实站 vs 仿真站/本地 demo
## 如何选择示例
| 你的场景 | 参考目录 |

View File

@@ -131,7 +131,7 @@ real_browser_rpa/
`account_client.py` 末尾的 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 是**开发环境兜底路径**,仅用于模板/本机调试。复制到真实 skill 后:
- **优先**通过 `ACCOUNT_MANAGER_ROOT``CLAW_SKILLS_ROOT``JIANGCHANG_SKILLS_ROOT` 或宿主运行环境解析 account-manager
- **优先**通过 `ACCOUNT_MANAGER_ROOT`Platform Kit `get_sibling_skills_root` 或宿主运行环境解析 account-manager
- **不要**把个人机器路径作为生产依赖
## 必须保留的安全原则

View File

@@ -9,6 +9,8 @@ import subprocess
import sys
from typing import Any, Dict, List, Optional
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
@@ -39,16 +41,16 @@ def mask_login_id(login_id: str) -> str:
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级ACCOUNT_MANAGER_ROOT → CLAW_SKILLS_ROOT / JIANGCHANG_SKILLS_ROOT → 开发机兜底。
优先级ACCOUNT_MANAGER_ROOT → get_sibling_skills_root路径推断 / JIANGCHANG_SKILLS_ROOT→ 开发机兜底。
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
"""
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
skills_root = (os.getenv("CLAW_SKILLS_ROOT") or os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
if skills_root:
candidate = os.path.join(os.path.abspath(skills_root), "account-manager", "scripts", "main.py")
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skills_root = get_sibling_skills_root(scripts_dir)
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
if os.path.isfile(candidate):
return candidate

View File

@@ -1,20 +1,20 @@
# Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 Playwright 完成登录、表单填写、弹窗确认、结果解析。
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。
本示例抽象自已跑通的「批量提交类」浏览器自动化 skill但已去业务化不包含真实银行/真实资金语义。
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制
## 适合什么场景
- 自有 **仿真系统 / sandbox / training 页面**
- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
- 需要 **浏览器 RPA** 做端到端演示或回归
- 表单较复杂多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**mockCI与 simulator_rpa半集成可切换
- 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**`mock`CI`simulator_rpa`(半集成)可切换
## 不适合什么场景
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA,后续参考 `real_api`
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
## 目录结构
@@ -23,22 +23,24 @@
simulator_browser_rpa/
├── README.md
├── sandbox/
│ └── demo_app.html # 本地仿真页面(稳定 data-testid / name
│ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM
├── scripts/
│ ├── service/
│ │ ├── browser_session.py # 浏览器启动URL 仅 page.goto
│ │ ├── account_client.py # 账号来源集中封装
│ │ ├── task_service.py # 编排层
│ │ ├── browser_session.py # async 启动 persistent context不含 goto
│ │ ├── account_client.py # account-manager subprocess对齐 real_browser_rpa
│ │ ├── simulator_playwright.py # RPA 主流程(厚)
│ │ ├── task_service.py # async 编排层
│ │ └── adapter/
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约
│ │ ├── mock.py # mock/unit 档
│ │ ├── simulator_rpa.py
│ │ ├── simulator_rpa.py # 薄 adapterpick / lease / 委托 RPA
│ │ └── __init__.py # select_adapter
│ └── util/
│ ├── constants.py
│ └── logging.py
└── tests/
├── test_adapter_dispatch.py
├── test_no_rpa_helpers_import.py
├── test_simulator_rpa_unit.py
└── test_stop_reason.py
```
@@ -47,91 +49,95 @@ simulator_browser_rpa/
| 文件 | 职责 |
|---|---|
| `browser_session.py` | 系统 Chrome/Edge + launch args 规范;支持 profile / 无 profile |
| `account_client.py` | 示例从环境变量读账号;真实 skill 可在此集中对接 account-manager |
| `adapter/base.py` | 数据契约与 `BatchAdapterBase` |
| `browser_session.py` | async 系统 Chrome/Edge + launch args**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease` |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapterpick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `adapter/simulator_rpa.py` | 仿真 RPA 主流程 |
| `task_service.py` | 校验输入 → 选 adapter → 提交 → 返回 summary |
| `sandbox/demo_app.html` | 可控 DOM配合 selector 稳定 |
| `tests/` | dispatch、URL 不进 launch args、失败转结果 |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM含可选门户门闩 + 原批量提交流程 |
## 核心流程
1. `task_service.run_batch_submit(target, items)` 校验参数
1. `await run_batch_submit(target, items)` 校验参数
2. `select_adapter()``OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
3. simulator 档:`pick_simulator_account()` `set_credentials()``submit_batch()`
4. RPA登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch` `pick_web_account(sim_batch)``submit_batch_rpa``release_lease`
4. RPA门户门闩(若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
## 复制到新 skill 时怎么做
## 两档仿真目标
按 copy map **选择性复制**到 `scripts/`**不要**整包照搬本目录,**不要**把本 README 当作新技能根 README。
| 档位 | 目标 | 说明 |
|---|---|---|
| 本地教学 | `sandbox/demo_app.html``file://` | 结构教学 + mock 单测默认 |
| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 |
联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`
## account-manager 注册示例(泛化)
```bash
python .../account-manager/scripts/main.py account add-web \
--platform sim_batch \
--login-id demo001 \
--url file:///.../demo_app.html \
--environment simulator \
--auth-strategy per_session_manual
```
线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。
**PIN/密码演示值**RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。
## Copy map复制到新技能 `scripts/`
| 来源examples | 目标(新技能) | 说明 |
|---|---|---|
| `examples/simulator_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | URL 仅 `page.goto`,不进 launch args |
| `examples/simulator_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | 账号来源集中封装 |
| `examples/simulator_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 编排层,合并业务逻辑 |
| `examples/simulator_browser_rpa/scripts/service/adapter/` | `scripts/service/<domain>_adapter/` | **adapter 分层权威参考** |
| `examples/simulator_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 须替换业务常量 |
| `examples/simulator_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选 |
| `examples/simulator_browser_rpa/tests/` | `tests/` | 可按需复制 dispatch / unit 测试 |
| `examples/simulator_browser_rpa/sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill |
| `examples/simulator_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
**边界说明:**
- `sandbox/` 只用于仿真测试与本地回归,**不进入**真实生产 skill
- `adapter/` 分层base / mock / simulator_rpa是仿真浏览器 RPA 的权威参考
- `mock` / `simulator_rpa` 只用于测试与仿真,**不要**混入 `real_browser_rpa` 的真实站点逻辑
- `demo_app.html` 是可运行沙箱页面,**不是**真实业务系统
**保留:**
- adapter 分层base / mock / simulator_rpa / `select_adapter`
- `browser_session.py` 启动规范(**URL 不进 launch args**
- `account_client.py` 集中封装模式
- 关键节点结构化日志 + 失败截图
- 测试dispatch、launch args、错误转 `BatchSubmitResult`
**替换:**
- `BatchItem` 字段与校验规则
- `sandbox/demo_app.html``SIMULATOR_BASE_URL` 指向的页面
- 全部 DOM selector必须 F12 实测)
- `task_service.py` 输入输出与 DB/任务日志
- mock 返回字段、成功页 `batch_id` 解析逻辑
| `browser_session.py` | `scripts/service/browser_session.py` | asyncURL 仅 `page.goto`,不进 launch args |
| `account_client.py` | `scripts/service/account_client.py` | subprocess对齐 `real_browser_rpa` |
| `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) |
| `adapter/` | `scripts/service/<domain>_adapter/` | `simulator_rpa` + mock |
| `task_service.py` | `scripts/service/task_service.py` | async 编排 |
| `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 |
| `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 |
| `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill |
## 明确禁止
- 不要复制 `__pycache__``.pytest_cache``artifacts/``recordings/` 等生成物
- **不要在技能侧**执行 `playwright install` / `pip install playwright`Python 包由宿主共享 runtime 提供)
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要** `import account-manager` 内部模块(`rpa_helpers``inject_account_manager_scripts_path``get_account_credential`
- **不要** sync Playwright 用于完整 RPA 主路径
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
- **不要把真实生产系统**配置到 `simulator_rpa` 档位
- 不要照抄 slug、logger 名、演示账号到生产 skill
- **不要在技能侧**执行 `playwright install` / `pip install playwright`
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要** vendor `scripts/jiangchang_skill_core/`
- **不要**照抄 slug、logger 名、演示账号到生产 skill
> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。
## 联调检查表
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队)
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过mock 离线)
## 依赖说明
- Python 3.10+
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要写入 `requirements.txt`,不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**,不使用 Playwright 内置 Chromium
- 技能侧不要执行 `playwright install` / `playwright install chromium`
- 本地独立开发若缺 Playwright Python 包,仅作开发机临时补装;生产由宿主解决
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选,用于查找系统浏览器)
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install`
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选)
## 环境变量(示例)
| 变量 | 说明 |
|---|---|
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock默认 → Simulator RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `sandbox/demo_app.html` 的 file URI |
| `SIMULATOR_LOGIN_ID` / `PASSWORD` / `TOKEN_PIN` | 演示账号 |
| `SIMULATOR_PROFILE_DIR` | 可选 persistent profile |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头模式CI 可用) |
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock`simulator_rpa` → 仿真 RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `demo_app.html` 的 file URI |
| `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) |
| `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120 |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头CI 可用) |
## 运行测试
@@ -140,9 +146,9 @@ cd examples/simulator_browser_rpa
python -m pytest tests/ -v
```
测试为 mock/纯函数,**不需要启动真实浏览器**。
测试为 mock/纯函数,**不启动真实浏览器**。
## 当前状态
- 示例代码与 sandbox 页面已就绪,可作为仿真浏览器 RPA 复制起点
- async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`
- 未接入完整 CLI、数据库与 entitlement。

View File

@@ -15,7 +15,16 @@
<body>
<h1>仿真业务系统 · 批量提交(示例)</h1>
<section id="login-step1-section">
<section id="portal-section">
<h2>平台门户登录(示例)</h2>
<form name="portalGate">
<label>门户账号 <input id="portal-user" name="portalUser" /></label>
<label>门户密码 <input id="portal-pass" name="portalPass" type="password" /></label>
<button type="button" id="portal-enter">进入业务系统</button>
</form>
</section>
<section id="login-step1-section" class="hidden">
<h2>登录 · 第一步</h2>
<form name="simLoginStep1" data-testid="login-step1">
<label>账号 <input name="loginId" data-testid="login-id" /></label>
@@ -69,6 +78,11 @@
const DEMO_CAPTCHA = "0000";
let rowCount = 0;
document.getElementById('portal-enter').addEventListener('click', function () {
document.getElementById('portal-section').classList.add('hidden');
document.getElementById('login-step1-section').classList.remove('hidden');
});
document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) {
e.preventDefault();
document.getElementById('login-step1-section').classList.add('hidden');

View File

@@ -1,40 +1,199 @@
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager)。"""
"""account-manager CLI 集成(仅 subprocess不 import 兄弟 skill 内部模块)。"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from typing import Optional
import subprocess
import sys
from typing import Any, Dict, List, Optional
from util.constants import (
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
ENV_SIMULATOR_LOGIN_ID,
ENV_SIMULATOR_PASSWORD,
ENV_SIMULATOR_PROFILE_DIR,
ENV_SIMULATOR_TOKEN_PIN,
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}"
"status=active、带 profile_dir 的账号后重新运行。"
)
@dataclass
class SimulatorAccount:
login_id: str
password: str
token_pin: str
profile_dir: str = ""
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
def pick_simulator_account() -> SimulatorAccount:
class AccountManagerError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def mask_login_id(login_id: str) -> str:
return mask_text(login_id)
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
"""
示例账号来源:优先读环境变量,否则返回内置 demo 账号。
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
真实 skill 若依赖 account-manager应把 subprocess / sibling_bridge 调用
集中在本模块,不要散落到 task_service 或 RPA 主流程中。
"""
return SimulatorAccount(
login_id=(os.getenv(ENV_SIMULATOR_LOGIN_ID) or DEFAULT_DEMO_LOGIN_ID).strip(),
password=(os.getenv(ENV_SIMULATOR_PASSWORD) or DEFAULT_DEMO_PASSWORD).strip(),
token_pin=(os.getenv(ENV_SIMULATOR_TOKEN_PIN) or DEFAULT_DEMO_TOKEN_PIN).strip(),
profile_dir=(os.getenv(ENV_SIMULATOR_PROFILE_DIR) or "").strip(),
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skills_root = get_sibling_skills_root(scripts_dir)
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
if os.path.isfile(candidate):
return candidate
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
if os.path.isfile(dev):
return dev
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
"未找到 account-manager请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
)
def _parse_last_json(stdout: str) -> Any:
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
for raw in reversed(lines):
if raw.startswith("ERROR:"):
continue
try:
return json.loads(raw)
except json.JSONDecodeError:
continue
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
main_py = _resolve_account_manager_main()
env = os.environ.copy()
return subprocess.run(
[sys.executable, main_py, *argv_suffix],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
def _normalize_error_code(raw: str) -> str:
code = (raw or "").strip()
if code.startswith("ERROR:"):
code = code[6:]
return code
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
return payload
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
proc = _run_argv(
[
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
return payload
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
raw = _parse_last_json(out)
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
data = raw["data"]
elif isinstance(raw, dict):
data = raw
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
f"账号 {account_id} 不是 {platform} 平台账号。",
)
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号profile_dir + lease_token"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try:
return _pick_web_with_lease(platform_key)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
raise
def release_lease(lease_token: Optional[str]) -> None:
token = (lease_token or "").strip()
if not token:
return
try:
proc = _run_argv(["lease", "release", token])
if proc.returncode != 0:
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
except Exception:
logger.warning("lease_release_exception", exc_info=True)

View File

@@ -3,9 +3,10 @@
from __future__ import annotations
import logging
import os
from typing import Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.adapter.mock import MockBatchAdapter
from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter
@@ -23,7 +24,7 @@ __all__ = [
def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase:
target = os.environ.get("OPENCLAW_TEST_TARGET", "").strip().lower()
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
if target in ("mock", "unit"):
logger.info("target '%s': MockBatchAdapter", target)

View File

@@ -28,5 +28,5 @@ class BatchSubmitResult:
class BatchAdapterBase:
name: str = "base"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
raise NotImplementedError

View File

@@ -11,7 +11,7 @@ from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
class MockBatchAdapter(BatchAdapterBase):
name = "mock"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
del target
if not items:
return BatchSubmitResult(

View File

@@ -1,77 +1,45 @@
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交"""
"""仿真浏览器 RPA 薄 adapterpick 账号、lease、委托 RPA、release lease"""
from __future__ import annotations
import logging
import os
import random
import re
import time
from typing import List, Optional
from jiangchang_skill_core import config
from service.account_client import AccountManagerError, pick_web_account, release_lease
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.browser_session import browser_session, find_chrome_executable
from util.constants import (
DEFAULT_DEMO_CAPTCHA,
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
resolve_simulator_base_url,
)
from service.browser_session import find_chrome_executable
from service.simulator_playwright import submit_batch_rpa
from util.constants import TARGET_PLATFORM, resolve_simulator_base_url
logger = logging.getLogger(__name__)
_PAUSE_MIN_MS = 500
_PAUSE_MAX_MS = 2000
class RpaError(Exception):
def __init__(self, message: str, screenshot_path: Optional[str] = None):
super().__init__(message)
self.screenshot_path = screenshot_path
def _resolve_entry_url(account: dict) -> str:
url = str(account.get("url") or "").strip()
if url:
return url.rstrip("/")
return resolve_simulator_base_url()
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
name = "simulator_browser_rpa"
name = "simulator_rpa"
def __init__(
self,
base_url: Optional[str] = None,
headless: Optional[bool] = None,
artifacts_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> None:
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
self.artifacts_dir = artifacts_dir
if headless is None:
env = os.environ.get("OPENCLAW_BROWSER_HEADLESS", "").strip().lower()
self.headless = env in ("1", "true", "yes")
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
else:
self.headless = headless
self.artifacts_dir = artifacts_dir
self._login_id: str = DEFAULT_DEMO_LOGIN_ID
self._password: str = DEFAULT_DEMO_PASSWORD
self._token_pin: str = DEFAULT_DEMO_TOKEN_PIN
self._profile_dir: Optional[str] = None
def set_credentials(
self,
login_id: str,
password: str,
token_pin: str,
profile_dir: str,
) -> None:
self._login_id = login_id
self._password = password
self._token_pin = token_pin
stripped = (profile_dir or "").strip()
self._profile_dir = stripped or None
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
try:
from playwright.sync_api import sync_playwright # noqa: F401
from playwright.async_api import async_playwright # noqa: F401
except ImportError:
return BatchSubmitResult(
ok=False,
@@ -105,236 +73,37 @@ class SimulatorBrowserRpaAdapter(BatchAdapterBase):
artifacts={"adapter": self.name},
)
start_url = self.base_url
lease_token: Optional[str] = None
try:
with browser_session(
start_url,
profile_dir=self._profile_dir,
account = pick_web_account(TARGET_PLATFORM)
lease_token = account.get("lease_token")
base_url = _resolve_entry_url(account)
return await submit_batch_rpa(
account,
target=target,
items=items,
base_url=base_url,
artifacts_dir=self.artifacts_dir,
headless=self.headless,
) as page:
try:
logger.info("rpa_login_start url=%s", start_url)
self._login(page)
logger.info("rpa_login_done")
self._goto_batch_page(page)
logger.info("rpa_batch_page_ready")
self._fill_batch_form(page, target, items)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = self._submit_and_get_batch_id(page)
logger.info("rpa_submit_success batch_id=%s", batch_id)
total = sum(it.amount for it in items)
return BatchSubmitResult(
ok=True,
batch_id=batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": self.name},
)
except RpaError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": self.name}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
except AccountManagerError as exc:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=str(exc),
artifacts=arts,
error_msg=f"ERROR:{exc.code} {exc.message}",
artifacts={"adapter": self.name},
)
except Exception as exc:
sp = self._safe_screenshot(page, "unexpected_error")
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": self.name}
if sp:
arts["screenshot"] = sp
logger.exception("adapter_unexpected: %s", exc)
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
except Exception as outer:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"启动浏览器失败:{outer}",
artifacts={"adapter": self.name},
)
def _pause(self, min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)
def _login(self, page) -> None:
try:
page.wait_for_selector('form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep1"] input[name="loginId"]', self._login_id)
self._pause()
page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep2"] input[name="password"]', self._password)
page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
self._pause()
page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
def _goto_batch_page(self, page) -> None:
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
except Exception as exc:
sp = self._safe_screenshot(page, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
def _select_target_account(self, page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = sel.locator("option").count()
matched_value: Optional[str] = None
first_value: Optional[str] = None
for i in range(n):
opt = sel.locator("option").nth(i)
val = opt.get_attribute("value") or ""
if not val.strip():
continue
label_txt = opt.inner_text()
if first_value is None:
first_value = val
if target.strip() and target.strip() in label_txt:
matched_value = val
break
pick = matched_value or first_value
if not pick:
raise RpaError("来源账户下拉无可选项")
if not matched_value and target.strip():
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
sel.select_option(value=pick)
self._pause()
def _fill_batch_form(self, page, target: str, items: List[BatchItem]) -> None:
try:
page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
self._select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = self._safe_screenshot(page, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = page.locator("tr[data-row-index]").count()
need_more = max(0, len(items) - existing)
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
for _ in range(need_more):
self._pause(400, 900)
add_btn.click()
except Exception as exc:
sp = self._safe_screenshot(page, "add_rows_failed")
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
for it in items:
try:
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
self._pause(300, 800)
row.locator('input[name="name"]').fill(it.name)
row.locator('input[name="account"]').fill(it.account)
row.locator('input[name="note"]').fill(it.note or "")
row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = self._safe_screenshot(page, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
def _submit_and_get_batch_id(self, page) -> str:
try:
self._pause(800, 1500)
page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "pin_dialog_not_open")
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
try:
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
self._pause(500, 1200)
pin_input.fill(self._token_pin)
except Exception as exc:
sp = self._safe_screenshot(page, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = page.locator('[data-testid="batch-id"]').inner_text().strip()
url = page.url
if not batch_id and "#/batch/" in url:
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
if not batch_id:
sp = self._safe_screenshot(page, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
def _safe_screenshot(self, page, tag: str) -> Optional[str]:
if not self.artifacts_dir:
return None
try:
os.makedirs(self.artifacts_dir, exist_ok=True)
path = os.path.join(self.artifacts_dir, f"{tag}_{int(time.time())}.png")
page.screenshot(path=path, full_page=True)
logger.info("screenshot_saved path=%s", path)
return path
except Exception as exc:
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
return None
finally:
release_lease(lease_token)

View File

@@ -1,16 +1,14 @@
"""浏览器会话启动(系统 Chrome/EdgeURL 仅通过 page.goto 打开)。"""
"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。"""
from __future__ import annotations
import logging
import os
from contextlib import contextmanager
from typing import Any, Generator, Iterator, List, Optional, Tuple
from typing import Any, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_000
GOTO_TIMEOUT_MS = 60_000
CHROME_LAUNCH_ARGS: List[str] = [
"--start-maximized",
@@ -33,11 +31,11 @@ def _headless_from_env() -> bool:
return v in ("1", "true", "yes", "on")
def _find_chrome_executable() -> str | None:
def find_chrome_executable() -> str | None:
try:
from jiangchang_skill_core.runtime_env import find_chrome_executable
from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
chrome = find_chrome_executable()
chrome = _find()
if chrome and os.path.isfile(chrome):
return chrome
except ImportError:
@@ -59,46 +57,39 @@ def _clear_node_options() -> None:
os.environ.pop("NODE_OPTIONS", None)
@contextmanager
def browser_session(
start_url: str,
async def start_browser_session(
profile_dir: str,
*,
profile_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> Generator[Any, None, None]:
) -> Tuple[Any, Any, Any]:
"""
同步 Playwright 浏览器会话
启动持久化 Chrome profile返回 (playwright, context, page)
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install
- launch args 仅放 Chrome 参数,不含 URL。
- 流程launch → new_page → goto(start_url)
URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto
"""
from playwright.sync_api import sync_playwright
from playwright.async_api import async_playwright
_clear_node_options()
hl = headless if headless is not None else _headless_from_env()
chrome = _find_chrome_executable()
chrome = find_chrome_executable()
if not chrome:
raise RuntimeError(
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
)
args = chrome_launch_args()
logger.info(
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
chrome,
profile_dir or "",
start_url,
profile_dir,
args,
hl,
)
browser_holder = None
pw = await async_playwright().start()
context = None
with sync_playwright() as pw:
try:
if profile_dir:
context = pw.chromium.launch_persistent_context(
context = await pw.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=hl,
executable_path=chrome,
@@ -107,32 +98,29 @@ def browser_session(
args=args,
ignore_default_args=["--enable-automation"],
)
else:
browser_holder = pw.chromium.launch(
headless=hl,
executable_path=chrome,
args=args,
ignore_default_args=["--enable-automation"],
)
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
await context.add_init_script(STEALTH_INIT_SCRIPT)
context.add_init_script(STEALTH_INIT_SCRIPT)
page = context.new_page()
page = context.pages[0] if context.pages else await context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
yield page
finally:
return pw, context, page
except Exception:
if context is not None:
try:
await context.close()
except Exception:
pass
await pw.stop()
raise
async def close_browser_session(playwright: Any, context: Any) -> None:
try:
if context is not None:
context.close()
except Exception as exc:
logger.warning("browser_context_close_failed: %s", exc)
if browser_holder is not None:
await context.close()
except Exception:
pass
try:
browser_holder.close()
except Exception as exc:
logger.warning("browser_close_failed: %s", exc)
def find_chrome_executable() -> str | None:
return _find_chrome_executable()
if playwright is not None:
await playwright.stop()
except Exception:
pass

View File

@@ -0,0 +1,400 @@
"""仿真批量提交 RPA 主流程async Playwright"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import re
import time
from dataclasses import dataclass
from typing import Any, List, Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchItem, BatchSubmitResult
from service.browser_session import (
close_browser_session,
find_chrome_executable,
start_browser_session,
)
from util.constants import (
DEFAULT_DEMO_CAPTCHA,
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
ENV_SIMULATOR_PASSWORD,
ENV_SIMULATOR_TOKEN_PIN,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
portal_login_wait_sec,
)
logger = logging.getLogger(__name__)
GOTO_TIMEOUT_MS = 60_000
_PAUSE_MIN_MS = 500
_PAUSE_MAX_MS = 2000
class RpaError(Exception):
def __init__(self, message: str, screenshot_path: Optional[str] = None):
super().__init__(message)
self.screenshot_path = screenshot_path
@dataclass
class _Credentials:
login_id: str
password: str
token_pin: str
def _resolve_credentials(account: dict[str, Any]) -> _Credentials:
login_id = str(
account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID
).strip()
password = str(
account.get("password")
or os.getenv(ENV_SIMULATOR_PASSWORD)
or config.get(ENV_SIMULATOR_PASSWORD)
or DEFAULT_DEMO_PASSWORD
).strip()
token_pin = str(
account.get("token_pin")
or account.get("pin")
or os.getenv(ENV_SIMULATOR_TOKEN_PIN)
or config.get(ENV_SIMULATOR_TOKEN_PIN)
or DEFAULT_DEMO_TOKEN_PIN
).strip()
return _Credentials(login_id=login_id, password=password, token_pin=token_pin)
def _headless() -> bool:
return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
async def _visible(locator) -> bool:
try:
return await locator.count() > 0 and await locator.is_visible(timeout=500)
except Exception:
return False
async def _wait_portal_gate(page, wait_sec: int) -> None:
portal_user = page.locator("#portal-user")
try:
if not await _visible(portal_user):
return
except Exception:
return
print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...")
deadline = time.monotonic() + wait_sec
while time.monotonic() < deadline:
try:
portal_section = page.locator("#portal-section")
login_step1 = page.locator('form[name="simLoginStep1"]')
if not await _visible(portal_section) or await _visible(login_step1):
print("[门户] 门户门闩已通过,进入业务系统登录")
return
except Exception:
pass
await asyncio.sleep(2.0)
raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。")
async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector(
'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS
)
await _pause()
await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id)
await _pause()
await page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password)
await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
await _pause()
await page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
async def _goto_batch_page(page, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
async def _select_target_account(page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = await sel.locator("option").count()
matched_value: Optional[str] = None
first_value: Optional[str] = None
for i in range(n):
opt = sel.locator("option").nth(i)
val = (await opt.get_attribute("value")) or ""
if not val.strip():
continue
label_txt = await opt.inner_text()
if first_value is None:
first_value = val
if target.strip() and target.strip() in label_txt:
matched_value = val
break
pick = matched_value or first_value
if not pick:
raise RpaError("来源账户下拉无可选项")
if not matched_value and target.strip():
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
await sel.select_option(value=pick)
await _pause()
async def _fill_batch_form(
page, target: str, items: List[BatchItem], artifacts_dir: str | None
) -> None:
try:
await page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
await _select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = await page.locator("tr[data-row-index]").count()
need_more = max(0, len(items) - existing)
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
for _ in range(need_more):
await _pause(400, 900)
await add_btn.click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "add_rows_failed")
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
for it in items:
try:
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
await row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
await _pause(300, 800)
await row.locator('input[name="name"]').fill(it.name)
await row.locator('input[name="account"]').fill(it.account)
await row.locator('input[name="note"]').fill(it.note or "")
await row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
await page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str:
try:
await _pause(800, 1500)
await page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "pin_dialog_not_open")
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
try:
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
await _pause(500, 1200)
await pin_input.fill(token_pin)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = (await page.locator('[data-testid="batch-id"]').inner_text()).strip()
url = page.url
if not batch_id and "#/batch/" in url:
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
if not batch_id:
sp = await _safe_screenshot(page, artifacts_dir, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]:
if not artifacts_dir:
return None
try:
os.makedirs(artifacts_dir, exist_ok=True)
path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png")
await page.screenshot(path=path, full_page=True)
logger.info("screenshot_saved path=%s", path)
return path
except Exception as exc:
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
return None
async def submit_batch_rpa(
account: dict[str, Any],
*,
target: str,
items: list[BatchItem],
base_url: str,
artifacts_dir: str | None,
video_session: Any = None,
headless: bool | None = None,
) -> BatchSubmitResult:
del video_session # 示例占位;真实 skill 可接入 RpaVideoSession
profile_dir = str(account.get("profile_dir") or "").strip()
if not profile_dir:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="账号缺少 profile_dir",
artifacts={"adapter": "simulator_rpa"},
)
if not items:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="items 为空",
artifacts={"adapter": "simulator_rpa"},
)
if not find_chrome_executable():
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
artifacts={"adapter": "simulator_rpa"},
)
creds = _resolve_credentials(account)
portal_wait = portal_login_wait_sec()
hl = headless if headless is not None else _headless()
pw = None
context = None
page = None
try:
pw, context, page = await start_browser_session(profile_dir, headless=hl)
logger.info("rpa_goto url=%s", base_url)
await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
await _wait_portal_gate(page, portal_wait)
logger.info("rpa_login_start")
await _login(page, creds, artifacts_dir)
logger.info("rpa_login_done")
await _goto_batch_page(page, artifacts_dir)
logger.info("rpa_batch_page_ready")
await _fill_batch_form(page, target, items, artifacts_dir)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir)
logger.info("rpa_submit_success batch_id=%s", batch_id)
total = sum(it.amount for it in items)
return BatchSubmitResult(
ok=True,
batch_id=batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": "simulator_rpa"},
)
except RpaError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": "simulator_rpa"}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
elif page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "rpa_error")
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=str(exc),
artifacts=arts,
)
except Exception as exc:
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": "simulator_rpa"}
if page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error")
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
finally:
await close_browser_session(pw, context)

View File

@@ -7,8 +7,7 @@ import tempfile
import uuid
from typing import Any, Dict, List, Optional
from service.account_client import pick_simulator_account
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter, select_adapter
from service.adapter import BatchItem, select_adapter
from util.logging import get_logger
logger = get_logger(__name__)
@@ -42,7 +41,7 @@ def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optio
return parsed, None
def run_batch_submit(
async def run_batch_submit(
target: str,
items: List[Dict[str, Any]],
*,
@@ -53,6 +52,7 @@ def run_batch_submit(
最小编排示例。
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
"""
del force # 示例占位;真实 skill 可用于跳过确认
@@ -70,19 +70,15 @@ def run_batch_submit(
uuid.uuid4().hex[:8],
)
os.makedirs(art_dir, exist_ok=True)
logger.info("batch_submit_start target=%s item_count=%s artifacts_dir=%s", target_key, len(batch_items), art_dir)
adapter = select_adapter(artifacts_dir=art_dir)
if isinstance(adapter, SimulatorBrowserRpaAdapter):
account = pick_simulator_account()
adapter.set_credentials(
account.login_id,
account.password,
account.token_pin,
account.profile_dir,
logger.info(
"batch_submit_start target=%s item_count=%s artifacts_dir=%s",
target_key,
len(batch_items),
art_dir,
)
result = adapter.submit_batch(target_key, batch_items)
adapter = select_adapter(artifacts_dir=art_dir)
result = await adapter.submit_batch(target_key, batch_items)
summary: Dict[str, Any] = {
"success": result.ok,
"target": target_key,

View File

@@ -5,12 +5,19 @@ from __future__ import annotations
import os
from pathlib import Path
from jiangchang_skill_core import config
SKILL_SLUG = "example-simulator-browser-rpa"
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
TARGET_PLATFORM = "sim_batch"
LEASE_HOLDER = "example-simulator-browser-rpa"
LEASE_TTL_SEC = "1800"
DEFAULT_TIMEOUT_MS = 15_000
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120
DEFAULT_DEMO_LOGIN_ID = "demo001"
DEFAULT_DEMO_PASSWORD = "demo-password"
@@ -18,15 +25,26 @@ DEFAULT_DEMO_CAPTCHA = "0000"
DEFAULT_DEMO_TOKEN_PIN = "123456"
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
ENV_SIMULATOR_LOGIN_ID = "SIMULATOR_LOGIN_ID"
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
ENV_SIMULATOR_PROFILE_DIR = "SIMULATOR_PROFILE_DIR"
ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC"
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
def portal_login_wait_sec() -> int:
raw = (
os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC)
or config.get(ENV_PORTAL_LOGIN_WAIT_SEC)
or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC)
)
try:
return max(1, int(str(raw).strip()))
except (TypeError, ValueError):
return DEFAULT_PORTAL_LOGIN_WAIT_SEC
def resolve_simulator_base_url() -> str:
env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip()
if env:

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import logging
import re
from typing import Any
from util.constants import LOG_LOGGER_NAME
@@ -13,3 +15,24 @@ def get_logger(name: str | None = None) -> logging.Logger:
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
s = (value or "").strip()
if len(s) >= 11 and s.isdigit():
return s[:3] + "****" + s[-4:]
if len(s) > keep_start + keep_end:
return s[:keep_start] + "****" + s[-keep_end:]
return "****"
def safe_log_value(value: Any) -> str:
if value is None:
return ""
text = str(value)
if re.fullmatch(r"\d{7,}", text):
return mask_text(text)
if "@" in text and len(text) > 6:
local, _, domain = text.partition("@")
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
return text

View File

@@ -42,20 +42,20 @@ class TestAdapterDispatch(unittest.TestCase):
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
class TestMockAdapter(unittest.TestCase):
def test_mock_success(self) -> None:
class TestMockAdapter(unittest.IsolatedAsyncioTestCase):
async def test_mock_success(self) -> None:
items = [
BatchItem(row_index=1, name="Alice", account="1001", amount=10.5),
BatchItem(row_index=2, name="Bob", account="1002", amount=20.0),
]
result = MockBatchAdapter().submit_batch("acct-demo-001", items)
result = await MockBatchAdapter().submit_batch("acct-demo-001", items)
self.assertTrue(result.ok)
self.assertEqual(result.submitted_count, 2)
self.assertEqual(result.submitted_amount, 30.5)
self.assertTrue((result.batch_id or "").startswith("MOCK-"))
def test_mock_empty_items(self) -> None:
result = MockBatchAdapter().submit_batch("acct-demo-001", [])
async def test_mock_empty_items(self) -> None:
result = await MockBatchAdapter().submit_batch("acct-demo-001", [])
self.assertFalse(result.ok)
self.assertEqual(result.error_msg, "items 为空")

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""禁止 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from pathlib import Path
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts"
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
violations: list[str] = []
for root, _dirs, files in os.walk(SCRIPTS_ROOT):
for name in files:
if not name.endswith(".py"):
continue
path = Path(root) / name
text = path.read_text(encoding="utf-8")
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,31 +2,27 @@
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
from __future__ import annotations
import asyncio
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.browser_session import CHROME_LAUNCH_ARGS
from service.simulator_playwright import RpaError
class TestSimulatorRpaUnit(unittest.TestCase):
def test_set_credentials_and_empty_profile_dir(self) -> None:
class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase):
async def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
adapter.set_credentials("demo001", "pwd", "pin", " ")
self.assertEqual(adapter._login_id, "demo001")
self.assertIsNone(adapter._profile_dir)
def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
result = adapter.submit_batch("acct-demo-001", [])
result = await adapter.submit_batch("acct-demo-001", [])
self.assertFalse(result.ok)
self.assertIn("items", result.error_msg or "")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None)
def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
async def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
adapter = SimulatorBrowserRpaAdapter()
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = adapter.submit_batch("acct-demo-001", items)
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
msg = result.error_msg or ""
self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg)
@@ -38,38 +34,100 @@ class TestSimulatorRpaUnit(unittest.TestCase):
self.assertFalse(lowered.startswith("https://"))
self.assertFalse(lowered.startswith("file://"))
@patch("service.adapter.simulator_rpa.browser_session")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
def test_start_url_opened_via_goto_not_launch_args(
async def test_adapter_delegates_to_playwright_with_account_url(
self,
_chrome: MagicMock,
mock_session: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
) -> None:
from service.adapter.simulator_rpa import RpaError
from service.adapter.base import BatchSubmitResult
adapter = SimulatorBrowserRpaAdapter(base_url="file:///demo/demo_app.html")
mock_pick.return_value = {
"login_id": "demo001",
"profile_dir": "/tmp/profile",
"url": "file:///demo/demo_app.html",
"lease_token": "tok-1",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="login fail",
artifacts={"adapter": "simulator_rpa"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
with patch.object(adapter, "_login", side_effect=RpaError("login fail")):
result = adapter.submit_batch("acct-demo-001", items)
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
mock_session.assert_called_once()
called_start_url = mock_session.call_args[0][0]
self.assertEqual(called_start_url, "file:///demo/demo_app.html")
mock_rpa.assert_awaited_once()
call_kwargs = mock_rpa.call_args.kwargs
self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, called_start_url)
self.assertNotIn(arg, call_kwargs["base_url"])
def test_safe_screenshot_without_artifacts_dir(self) -> None:
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
page = MagicMock()
self.assertIsNone(adapter._safe_screenshot(page, "tag"))
@patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock)
async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None:
from service.simulator_playwright import submit_batch_rpa
page = AsyncMock()
page.goto = AsyncMock()
mock_start.return_value = (MagicMock(), MagicMock(), page)
account = {"profile_dir": "/tmp/p", "login_id": "demo001"}
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch(
"service.simulator_playwright._wait_portal_gate",
new_callable=AsyncMock,
return_value=None,
):
with patch(
"service.simulator_playwright._login",
new_callable=AsyncMock,
side_effect=RpaError("login fail"),
):
with patch(
"service.simulator_playwright.find_chrome_executable",
return_value="C:/Chrome/chrome.exe",
):
with patch(
"service.simulator_playwright.close_browser_session",
new_callable=AsyncMock,
):
result = await submit_batch_rpa(
account,
target="acct-demo-001",
items=items,
base_url="file:///demo/demo_app.html",
artifacts_dir=None,
)
self.assertFalse(result.ok)
page.goto.assert_awaited_once()
goto_url = page.goto.call_args[0][0]
self.assertEqual(goto_url, "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, goto_url)
async def test_safe_screenshot_without_artifacts_dir(self) -> None:
from service.simulator_playwright import _safe_screenshot
page = AsyncMock()
self.assertIsNone(await _safe_screenshot(page, None, "tag"))
class TestSyncHelpers(unittest.TestCase):
def test_asyncio_run_smoke(self) -> None:
async def _noop() -> bool:
return True
self.assertTrue(asyncio.run(_noop()))
if __name__ == "__main__":

View File

@@ -3,51 +3,70 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.adapter.simulator_rpa import RpaError
from service.adapter.base import BatchSubmitResult
class TestBatchSubmitFailureResult(unittest.TestCase):
class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase):
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session")
def test_rpa_error_converts_to_failed_result(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
async def test_rpa_error_converts_to_failed_result(
self,
_chrome: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
_release: MagicMock,
) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="登录第一步失败timeout",
artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch.object(adapter, "_login", side_effect=RpaError("登录第一步失败timeout", screenshot_path="/tmp/art/x.png")):
result = adapter.submit_batch("acct-demo-001", items)
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("登录第一步失败", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session")
def test_unexpected_error_includes_adapter_name(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
async def test_unexpected_error_includes_adapter_name(
self,
_chrome: MagicMock,
mock_pick: MagicMock,
mock_rpa: AsyncMock,
_release: MagicMock,
) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.side_effect = RuntimeError("boom")
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch.object(adapter, "_login", side_effect=RuntimeError("boom")):
result = adapter.submit_batch("acct-demo-001", items)
result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok)
self.assertIn("未预期异常", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
self.assertNotIn("screenshot", result.artifacts)

View File

@@ -1,38 +1,204 @@
# 数据存储模板
# 数据存储与数据管理展示规范
## 数据库路径
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py``scripts/db/connection.py``init_db()` 会一并初始化。
`{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db`
## 职责边界
## 通用任务日志表task_logs
| 层级 | 负责内容 |
|------|----------|
| **技能(本仓库)** | `SKILL.md.name` 中文技能名;英文物理表/字段名;`_jiangchang_*` 中文展示元数据;`CREATE TABLE` 字段物理顺序 |
| **宿主(匠厂)** | 读取元数据与 PRAGMA 顺序并展示;不猜测业务语义、不按英文字段名重排 |
模板默认建表:
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`SQL 文件里的 `-- 注释` 也不会成为可查询结构。
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info``cid`)。
| 字段 | 类型 | 说明 |
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
## 技能名称SKILL.md
```yaml
---
name: 中文业务名称
description: 中文技能说明
metadata:
openclaw:
slug: english-kebab-slug
---
```
- `name`:用户可见的中文业务名称(可含品牌,如 `GEO 文章生成``1688 联系人采集`)。
- `description`:能力说明,不是技能名称。
- `metadata.openclaw.slug`机器标识kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md);本文件仅描述数据库与 kebab-case 格式。
- 复制模板后不得将 `your-skill-slug``My Skill` 等占位内容作为正式技能发布名称。
## 数据库路径与命名
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db
```
- 数据库文件:英文 slug例如 `scrape-contacts.db``account-manager.db`(默认 `{skill-slug}.db`)。
- 业务表、字段:**英文 snake_case** 物理名,例如 `task_logs``created_at`
- **禁止**用中文作为 SQLite 真实表名或字段名。
## 元数据表(宿主兼容)
宿主读取字段(与 `jiangchang` `metadata-resolver.ts` 一致):
### `_jiangchang_tables`
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | INTEGER PK | 自增主键 |
| `task_type` | TEXT NOT NULL | 任务类型disburse / reconcile / verify ... |
| `target_id` | TEXT | 任务目标(账号、平台、客户等的 ID |
| `input_id` | TEXT | 输入对象 ID |
| `input_title` | TEXT | 输入对象标题快照 |
| `status` | TEXT NOT NULL | success / failed / partial / require_login 等 |
| `error_msg` | TEXT | 错误说明 |
| `result_summary` | TEXT | 结果摘要(建议存 JSON 字符串) |
| `created_at` | INTEGER | Unix 时间戳 |
| `updated_at` | INTEGER | Unix 时间戳 |
| `table_name` | 是 | 物理表名 |
| `display_name` | 是 | 中文表名(用户可见 |
| `description` | 否 | 表说明 |
| `sort_order` | 否 | 宿主目录排序;与字段顺序无关 |
| `visible` | 是 | `1` 展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 只读 / `0` 可写(业务语义) |
## 不同业务的字段映射建议
### `_jiangchang_columns`
| 业务场景 | task_type | target_id 含义 | input_id 含义 |
|---|---|---|---|
| 发布类 | publish | 账号 ID | 文章 ID |
| 工资代发 | disburse | 付款账户 | 工资表批次 ID |
| 对账 | reconcile | 银行 / 平台 | 对账批次 ID |
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 物理表名 |
| `column_name` | 是 | 物理字段名 |
| `display_name` | 是 | 中文字段名(用户可见) |
| `description` | 否 | 字段说明 |
| `display_order` | 否 | **遗留兼容字段;模板写入时固定 NULL重复初始化会清理历史非空值不得用于调整展示顺序** |
| `visible` | 是 | 是否展示 |
| `searchable` | 是 | 是否可搜索 |
| `editable` | 是 | 是否可编辑(与宿主权限结合) |
| `display_type` | 否 | 如 `text` / `textarea` / `datetime_unix_seconds`(标准时间字段见下文) |
| `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
写入应**幂等**`INSERT … ON CONFLICT DO UPDATE`),数据库升级时同步维护元数据。
## 业务字段物理顺序
用户在「数据管理」中看到的列顺序 = `CREATE TABLE` 定义顺序 = `PRAGMA table_info``cid` 顺序。
**宿主不会**按英文字段名、`display_order` 或语义规则重排。
推荐顺序(可按业务裁剪,但已有字段应遵守相对位置):
```text
1. 主键 id
2. 关联标识与核心业务字段
3. 普通业务字段
4. 状态、结果、错误等辅助字段
5. created_at如有
6. updated_at如有
## 标准时间字段(新技能统一规范)
新技能业务表若需要时间审计字段,**统一**采用 Unix **秒级**时间戳不要使用毫秒时间戳、ISO 字符串或 SQLite 文本 datetime。
| 项目 | 规范 |
|------|------|
| 物理字段 | `created_at INTEGER`、`updated_at INTEGER` |
| 存储格式 | Unix 秒级整数 |
| 展示元数据 `display_type` | `datetime_unix_seconds` |
| 中文名 | `created_at` → **创建时间**`updated_at` → **更新时间** |
| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果,不写回字符串) |
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
### 自动生成与维护
- **created_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
- **updated_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。
- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新;若技能所有 UPDATE 都经过统一 repository也可在 repository 层设置 `updated_at = unixepoch()`**不要**叠加两套机制。
实现参考:
- DDL 与 trigger`scripts/db/connection.py`、`scripts/db/timestamp_columns.py`
- 中文元数据种子:`scripts/db/display_metadata.py`
- 自检:`scripts/db/display_metadata_validator.py`
宿主(匠厂)只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。
> `datetime_unix_milliseconds`、`datetime_iso` 是宿主兼容能力,**不是**新技能模板推荐标准。
### 模板默认:`task_logs`
物理定义(权威顺序):
```sql
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
target_id TEXT,
input_id TEXT,
input_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
```
插入时可省略 `created_at``updated_at``updated_at` 在 UPDATE 时由 trigger `task_logs_set_updated_at` 自动维护(见 `scripts/db/timestamp_columns.py`)。
对应中文展示(由 `init_db()` 写入元数据表):
| 物理字段 | 中文名 |
|----------|--------|
| id | 编号 |
| task_type | 任务类型 |
| target_id | 目标编号 |
| input_id | 输入编号 |
| input_title | 输入标题 |
| status | 状态 |
| error_msg | 错误信息 |
| result_summary | 结果摘要 |
| created_at | 创建时间(`display_type = datetime_unix_seconds`,不可编辑) |
| updated_at | 更新时间(`display_type = datetime_unix_seconds`,不可编辑) |
表中文名:**任务日志**。
禁止事项:
- 不要把 `created_at` 放在第一列;
- 不要把 `id` 随意放在中间;
- 不要指望宿主按英文字段名排序;
- 若需调整历史表字段顺序,应通过规范迁移**重建表**,而不是在宿主侧配置掩盖。
复合主键或特殊表结构,请在本文档或技能自有 SCHEMA 补充中说明例外。
## 中文命名质量
- 使用普通用户能理解的业务用语:`task_logs`**任务日志**`error_msg`**错误信息**
- 禁止:`Task Logs``input编号``errormsg`、直接把物理名当 `display_name`
- 不宜向用户暴露的实现细节(如 `payload_json``selector`)应设 `visible = 0` 或给出可理解中文名。
## 元数据同步规则
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`
2. 新增用户可见字段 → 同步 `_jiangchang_columns`
3. 删除/重命名表或字段 → 同步清理或迁移元数据。
4. 初始化脚本必须幂等。
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`
## 不同业务的 `task_logs` 映射
| 业务场景 | task_type | target_id | input_id |
|----------|-----------|-----------|----------|
| 发布类 | publish | 账号 ID | 内容 ID |
| 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 银行/平台 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
| 报关 | declare | 港口 / 海关 | 报关批次 ID |
业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
## 开发自检
- `init_db()` 后运行 `tests/test_display_metadata.py`
- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。
## 模板原则
- 模板不做历史迁移兼容设计
- 新 skill 直接从当前 schema 起步
- 业务有特殊字段时,建议放 `result_summary`JSON 字符串),不要乱加列
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。

View File

@@ -1,3 +1,15 @@
<#
.SYNOPSIS
Local git release for skill-template repo.
.DESCRIPTION
Handles git commit / push / semver tag / push tag only.
Encryption, ZIP packaging, and marketplace sync are done by CI
(.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main).
Requires: git, PowerShell 5+
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
@@ -11,13 +23,228 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (-not (Test-Path $sharedScript)) {
throw "Shared release script not found: $sharedScript"
function Remove-GitNoise {
param([string[]]$Lines)
return @($Lines | Where-Object {
$_ -and ($_ -notmatch '^warning:\s+unable to access ')
})
}
& $sharedScript @PSBoundParameters
exit $LASTEXITCODE
function Invoke-Git {
param([Parameter(Mandatory = $true)][string]$Args)
Write-Host ">> git $Args" -ForegroundColor DarkGray
& cmd /c "git $Args 2>&1" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
}
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
return @(Remove-GitNoise -Lines @($output))
}
function Test-Repo {
$output = & cmd /c "git rev-parse --is-inside-work-tree 2>&1"
if ($LASTEXITCODE -ne 0) {
return $false
}
$clean = Remove-GitNoise -Lines @($output)
return (($clean | Select-Object -First 1) -eq "true")
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Assert-SkillReleasePackagingSources {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$scriptsDir = Join-Path $SkillRoot "scripts"
$mainPy = Join-Path $scriptsDir "main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
return
}
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$need = @()
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
foreach ($p in ($need | Select-Object -Unique)) {
$folder = Join-Path $scriptsDir $p
if (-not (Test-Path -LiteralPath $folder)) {
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
}
}
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
Remove-GitNoise -Lines @(& cmd /c "git status --short 2>&1") | ForEach-Object { Write-Host $_ }
throw "Abort: dirty working tree."
}
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow
} else {
Invoke-Git "fetch --tags --prune origin"
}
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
$null = Remove-GitNoise -Lines @(& cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1')
$hasUpstream = ($LASTEXITCODE -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
} else {
Invoke-Git "push -u origin $branch"
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import sqlite3
from db.display_metadata import init_display_metadata
from db.timestamp_columns import init_task_logs_timestamp_maintenance
from util.runtime_paths import get_db_path
@@ -26,11 +28,13 @@ def init_db() -> None:
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
init_task_logs_timestamp_maintenance(cur)
init_display_metadata(cur)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,185 @@
"""匠厂数据管理展示元数据_jiangchang_tables / _jiangchang_columns
SQLite 无原生表/字段 COMMENT中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
"""
from __future__ import annotations
from typing import Iterable
# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
TASK_LOGS_TABLE = "task_logs"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
_JIANGCHANG_TABLES_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
)
"""
_JIANGCHANG_COLUMNS_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
TASK_LOGS_TABLE_METADATA = {
"table_name": TASK_LOGS_TABLE,
"display_name": "任务日志",
"description": "记录技能任务的执行状态和结果",
"visible": 1,
"readonly": 0,
}
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 1},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间Unix 秒级时间戳,数据库自动生成)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"description": "记录最后更新时间Unix 秒级时间戳,自动维护)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
)
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
visible = excluded.visible,
readonly = excluded.readonly
""",
(table_name, display_name, description, visible, readonly),
)
def upsert_column_metadata(
cursor,
*,
table_name: str,
column_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
display_type: str | None = None,
) -> None:
# display_order 保留为 NULL宿主按 PRAGMA table_info cid 展示字段,不读 display_order。
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description,
display_order, visible, searchable, editable, display_type
)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
display_order = NULL,
visible = excluded.visible,
searchable = excluded.searchable,
editable = excluded.editable,
display_type = excluded.display_type
""",
(table_name, column_name, display_name, description, visible, searchable, editable, display_type),
)
def seed_task_logs_display_metadata(cursor) -> None:
meta = TASK_LOGS_TABLE_METADATA
upsert_table_metadata(
cursor,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]) if meta.get("description") else None,
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
for column in TASK_LOGS_COLUMN_METADATA:
upsert_column_metadata(
cursor,
table_name=TASK_LOGS_TABLE,
column_name=str(column["column_name"]),
display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None,
visible=int(column["visible"]),
searchable=int(column["searchable"]),
editable=int(column["editable"]),
display_type=str(column["display_type"]) if column.get("display_type") else None,
)
def init_display_metadata(cursor) -> None:
init_display_metadata_tables(cursor)
seed_task_logs_display_metadata(cursor)
def iter_user_tables(cursor) -> Iterable[str]:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
for (name,) in cursor.fetchall():
if name in METADATA_TABLES:
continue
yield str(name)
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def list_physical_column_names(cursor, table_name: str) -> list[str]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
rows = cursor.fetchall()
rows.sort(key=lambda row: int(row[0]))
return [str(row[1]) for row in rows]

View File

@@ -0,0 +1,378 @@
"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。"""
from __future__ import annotations
import os
import re
import sqlite3
from dataclasses import dataclass, field
from db.display_metadata import (
DATETIME_UNIX_SECONDS,
METADATA_TABLES,
STANDARD_TIMESTAMP_COLUMNS,
TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE,
iter_user_tables,
list_physical_column_names,
)
from db.timestamp_columns import has_updated_at_trigger
from util.constants import SKILL_SLUG
from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"})
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
re.compile(r"^my\s+skill$", re.I),
re.compile(r"^your[-_\s]?skill", re.I),
re.compile(r"^skill[-_\s]?template$", re.I),
re.compile(r"^[a-z0-9_-]+$"),
)
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
@dataclass
class ValidationReport:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def _has_cjk(text: str) -> bool:
return bool(_CJK_RE.search(text))
def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool:
if not display_name.strip():
return True
if display_name.strip().lower() == physical_name.strip().lower():
return True
compact = display_name.replace(" ", "").replace("_", "").lower()
if compact == physical_name.replace("_", "").lower():
return True
return False
def _parse_frontmatter_field(text: str, field_name: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
for line in parts[1].splitlines():
match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip())
if match:
return match.group(1).strip().strip("\"'")
return ""
def _parse_openclaw_slug(text: str) -> str:
parts = text.split("---", 2)
if len(parts) < 2:
return ""
lines = parts[1].splitlines()
for index, line in enumerate(lines):
if re.match(r"^\s+openclaw:\s*$", line):
for inner in lines[index + 1 :]:
if inner.strip() and inner[0] not in (" ", "\t"):
break
match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
if match:
return match.group(2).strip().strip("\"'")
return ""
def parse_skill_frontmatter(skill_root: str) -> dict[str, str]:
path = os.path.join(skill_root, "SKILL.md")
with open(path, encoding="utf-8") as f:
text = f.read()
return {
"name": _parse_frontmatter_field(text, "name"),
"slug": _parse_openclaw_slug(text),
}
def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport:
report = ValidationReport()
data = parse_skill_frontmatter(skill_root)
name = str(data.get("name") or "").strip()
slug = str(data.get("slug") or "").strip()
if not name:
report.errors.append("SKILL.md frontmatter.name 缺失")
elif not _has_cjk(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)")
else:
report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称")
else:
for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS:
if pattern.fullmatch(name):
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}")
else:
report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}")
break
if not slug:
report.errors.append("SKILL.md metadata.openclaw.slug 缺失")
elif not _KEBAB_SLUG.fullmatch(slug):
report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case{slug!r}")
elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders:
report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}")
constants_slug = SKILL_SLUG.strip()
if slug and constants_slug and slug != constants_slug:
report.warnings.append(
f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致"
)
if slug and slug not in LEGACY_EXEMPT_SLUGS and _KEBAB_SLUG.fullmatch(slug):
naming_errors, naming_warnings = validate_slug_semantics(
slug,
strict=not allow_template_placeholders,
)
report.errors.extend(naming_errors)
report.warnings.extend(naming_warnings)
return report
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
info: dict[str, dict[str, object]] = {}
for row in cursor.fetchall():
info[str(row[1])] = {
"cid": int(row[0]),
"name": str(row[1]),
"type": str(row[2] or ""),
"notnull": int(row[3] or 0),
"dflt_value": row[4],
"pk": int(row[5] or 0),
}
return info
def _default_uses_unixepoch(default_value: object) -> bool:
if default_value is None:
return False
text = str(default_value).strip().lower()
return "unixepoch" in text
def _expected_timestamp_display_name(column_name: str) -> str:
if column_name == "created_at":
return "创建时间"
if column_name == "updated_at":
return "更新时间"
return ""
def validate_standard_timestamp_columns(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
ordered = physical_columns or list_physical_column_names(cursor, table_name)
column_info = _table_column_info(cursor, table_name)
for column_name in STANDARD_TIMESTAMP_COLUMNS:
if column_name not in ordered:
continue
physical = column_info.get(column_name)
if not physical:
errors.append(f"{table_name}.{column_name} 缺少物理字段定义")
continue
if "INT" not in str(physical["type"]).upper():
errors.append(
f"{table_name}.{column_name} 字段类型应为 INTEGERUnix 秒级时间戳),当前为 {physical['type']!r}"
)
if column_name == "created_at" and not _default_uses_unixepoch(physical["dflt_value"]):
errors.append(
f"{table_name}.created_at 应设置数据库默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
)
if column_name == "updated_at" and not _default_uses_unixepoch(physical["dflt_value"]):
errors.append(
f"{table_name}.updated_at 应设置数据库初始默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
)
cursor.execute(
"""
SELECT display_name, display_type, editable, searchable, display_order
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
meta_row = cursor.fetchone()
if not meta_row:
errors.append(f"{table_name}.{column_name} 缺少 _jiangchang_columns 元数据")
continue
display_name, display_type, editable, searchable, display_order = meta_row
expected_name = _expected_timestamp_display_name(column_name)
if str(display_name or "").strip() != expected_name:
errors.append(
f"{table_name}.{column_name} 的 display_name 应为 {expected_name!r},当前为 {display_name!r}"
)
if str(display_type or "").strip() != DATETIME_UNIX_SECONDS:
errors.append(
f"{table_name}.{column_name} 的 display_type 应为 {DATETIME_UNIX_SECONDS!r},当前为 {display_type!r}"
)
if editable is None or int(editable) != 0:
errors.append(f"{table_name}.{column_name} 的 editable 应为 0系统时间字段不可编辑")
if display_order is not None:
errors.append(f"{table_name}.{column_name} 的 display_order 应为 NULL")
if searchable is not None and int(searchable) != 0:
warnings.append(f"{table_name}.{column_name} 的时间字段通常不必开启 searchable")
if "created_at" in ordered and "updated_at" in ordered:
if ordered.index("updated_at") < ordered.index("created_at"):
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
if "updated_at" in ordered and not has_updated_at_trigger(cursor, table_name):
errors.append(
f"{table_name}.updated_at 缺少自动维护 trigger期望 {table_name}_set_updated_at"
)
return errors, warnings
def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]:
warnings: list[str] = []
if "id" in ordered_names and ordered_names.index("id") != 0:
warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列")
if "created_at" in ordered_names:
idx = ordered_names.index("created_at")
trailing = ordered_names[idx + 1 :]
if trailing and trailing != ["updated_at"]:
warnings.append(
f"{table_name}: created_at 之后通常只应跟 updated_at不应再放置其他业务字段"
)
if "updated_at" in ordered_names and "created_at" in ordered_names:
if ordered_names.index("updated_at") < ordered_names.index("created_at"):
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
return warnings
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
(table_name,),
)
return cursor.fetchone() is not None
def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport:
report = ValidationReport()
cur = conn.cursor()
has_tables_meta = _table_exists(cur, "_jiangchang_tables")
has_columns_meta = _table_exists(cur, "_jiangchang_columns")
if not has_tables_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_tables")
if not has_columns_meta:
report.errors.append("缺少数据管理元数据表 _jiangchang_columns")
if not has_tables_meta or not has_columns_meta:
return report
for table_name in iter_user_tables(cur):
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cur.fetchone()
if not row or not str(row[0] or "").strip():
report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name")
elif not _has_cjk(str(row[0])):
report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称")
physical_columns = list_physical_column_names(cur, table_name)
if check_column_order:
report.warnings.extend(column_order_warnings(table_name, physical_columns))
for column_name in physical_columns:
cur.execute(
"""
SELECT display_name, visible
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
col_row = cur.fetchone()
if not col_row:
report.errors.append(
f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据"
)
continue
display_name = str(col_row[0] or "").strip()
visible = col_row[1] is None or int(col_row[1]) != 0
if visible and not display_name:
report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空")
elif visible and _looks_like_raw_physical_name(display_name, column_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}"
)
elif visible and not _has_cjk(display_name):
report.errors.append(
f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}"
)
ts_errors, ts_warnings = validate_standard_timestamp_columns(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(ts_errors)
report.warnings.extend(ts_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
continue
physical = list_physical_column_names(cur, str(table_name))
if str(column_name) not in physical:
report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}")
cur.execute("SELECT table_name FROM _jiangchang_tables")
for (table_name,) in cur.fetchall():
if table_name in METADATA_TABLES:
continue
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
(table_name,),
)
if not cur.fetchone():
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
return report
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
report = validate_display_metadata(conn)
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
if physical != expected:
report.errors.append(
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
)
return report

View File

@@ -9,7 +9,6 @@ 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(
@@ -22,17 +21,16 @@ def save_task_log(
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, status, error_msg, result_summary)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary, now, now),
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary),
)
new_id = int(cur.lastrowid)
conn.commit()

View File

@@ -0,0 +1,36 @@
"""标准时间字段Unix 秒级时间戳、默认值与 updated_at 自动维护。"""
from __future__ import annotations
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
TASK_LOGS_UPDATED_AT_TRIGGER = "task_logs_set_updated_at"
_TASK_LOGS_UPDATED_AT_TRIGGER_DDL = f"""
CREATE TRIGGER IF NOT EXISTS {TASK_LOGS_UPDATED_AT_TRIGGER}
AFTER UPDATE ON task_logs
FOR EACH ROW
BEGIN
UPDATE task_logs
SET updated_at = unixepoch()
WHERE id = NEW.id AND updated_at = OLD.updated_at;
END
"""
def init_task_logs_timestamp_maintenance(cursor) -> None:
"""幂等创建 task_logs.updated_at 自动维护 trigger。"""
cursor.execute(_TASK_LOGS_UPDATED_AT_TRIGGER_DDL)
def has_updated_at_trigger(cursor, table_name: str) -> bool:
trigger_name = f"{table_name}_set_updated_at"
cursor.execute(
"""
SELECT 1 FROM sqlite_master
WHERE type = 'trigger' AND name = ? AND tbl_name = ?
LIMIT 1
""",
(trigger_name, table_name),
)
return cursor.fetchone() is not None

View File

@@ -12,9 +12,9 @@ def check_entitlement(skill_slug: str) -> Tuple[bool, str]:
auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/")
if not auth_base:
return True, ""
user_id = (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
user_id = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
if not user_id:
return False, "鉴权失败:缺少用户身份(CLAW_USER_ID / JIANGCHANG_USER_ID"
return False, "鉴权失败缺少用户身份JIANGCHANG_USER_ID"
auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip()
timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip())

View File

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

227
scripts/util/slug_naming.py Normal file
View File

@@ -0,0 +1,227 @@
"""技能 slug 语义校验verb-noun-platform 规范,见 development/NAMING.md"""
from __future__ import annotations
import os
import re
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_MAX_SLUG_LENGTH = 48
_MIN_SEGMENTS = 3
_MAX_SEGMENTS = 5
_MAX_NOUN_SEGMENTS = 3
_SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_NAMING_DIR = os.path.join(_SKILL_ROOT, "assets", "naming")
_BUILTIN_VERBS = frozenset(
{
"scrape",
"receive",
"download",
"export",
"add",
"fill",
"enter",
"bind",
"withdraw",
"reconcile",
"verify",
"submit",
"query",
"process",
"reply",
"generate",
"draft",
"review",
"confirm",
"track",
"upload",
"match",
"triage",
"ship",
"reprice",
}
)
_BUILTIN_PLATFORMS = frozenset(
{
"amazon",
"shopee",
"xinghang",
"alibaba",
"kingdee",
"icbc",
"pingpong",
"worldfirst",
"paypal",
"lianlian",
"single-window",
"export-rebate",
"etax",
}
)
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"})
LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset(
{
"account-manager",
"skill-template",
"your-skill-slug",
"your_skill_slug",
}
)
def load_wordlist(filename: str) -> frozenset[str]:
"""从 assets/naming/{filename} 加载词表;文件不存在时 fallback 内置集合。"""
fallbacks = {
"verbs.txt": _BUILTIN_VERBS,
"platforms.txt": _BUILTIN_PLATFORMS,
"scopes.txt": _BUILTIN_SCOPES,
}
fallback = fallbacks.get(filename, frozenset())
path = os.path.join(_NAMING_DIR, filename)
if not os.path.isfile(path):
return fallback
words: set[str] = set()
with open(path, encoding="utf-8") as f:
for line in f:
word = line.strip()
if word and not word.startswith("#"):
words.add(word)
return frozenset(words) if words else fallback
def parse_slug_segments(slug: str) -> list[str]:
"""'-' 分割;非法字符由上层 kebab 校验处理。"""
slug = slug.strip()
if not slug:
return []
return slug.split("-")
def _match_platform_suffix(
segments: list[str],
platforms: frozenset[str],
) -> tuple[str, int] | None:
"""返回 (platform_name, platform_part_count) 或 None。"""
for platform in sorted(platforms, key=lambda item: (-item.count("-"), item)):
parts = platform.split("-")
part_count = len(parts)
if len(segments) < part_count + 2:
continue
if segments[-part_count:] == parts:
return platform, part_count
return None
def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
"""
返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid'
- legacy_exempt: slug in LEGACY_EXEMPT_SLUGS
- standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5
- scope: 末段 in scopes, 首段 in verbs, 3<=len<=5
- invalid: 其他
"""
if slug in LEGACY_EXEMPT_SLUGS:
return "legacy_exempt"
if not segments or not (_MIN_SEGMENTS <= len(segments) <= _MAX_SEGMENTS):
return "invalid"
verbs = load_wordlist("verbs.txt")
platforms = load_wordlist("platforms.txt")
scopes = load_wordlist("scopes.txt")
if segments[0] not in verbs:
return "invalid"
if segments[-1] in scopes:
return "scope"
if _match_platform_suffix(segments, platforms) is not None:
return "standard"
return "invalid"
def validate_slug_semantics(
slug: str,
*,
strict: bool = True,
) -> tuple[list[str], list[str]]:
"""返回 (errors, warnings)。"""
slug = slug.strip()
if slug in LEGACY_EXEMPT_SLUGS:
return [], []
errors: list[str] = []
warnings: list[str] = []
if not _KEBAB_SLUG.fullmatch(slug):
msg = f"slug 必须为 kebab-case小写字母、数字、连字符{slug!r}"
(errors if strict else warnings).append(msg)
return errors, warnings
if len(slug) > _MAX_SLUG_LENGTH:
msg = f"slug 长度不得超过 {_MAX_SLUG_LENGTH} 字符(当前 {len(slug)}{slug!r}"
(errors if strict else warnings).append(msg)
segments = parse_slug_segments(slug)
segment_count = len(segments)
if segment_count < _MIN_SEGMENTS:
msg = (
f"slug 段数应为 {_MIN_SEGMENTS}{_MAX_SEGMENTS}verb-noun-platform"
f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance"
)
(errors if strict else warnings).append(msg)
return errors, warnings
if segment_count > _MAX_SEGMENTS:
msg = f"slug 段数不得超过 {_MAX_SEGMENTS}(当前 {segment_count}{slug!r}"
(errors if strict else warnings).append(msg)
verbs = load_wordlist("verbs.txt")
platforms = load_wordlist("platforms.txt")
scopes = load_wordlist("scopes.txt")
if segments[0] in platforms and segments[0] not in verbs:
msg = f"平台名不应放在 slug 最前:{slug!r}(应为 verb-noun-platform"
(errors if strict else warnings).append(msg)
if segments[0] not in verbs:
msg = f"slug 首段应为动词白名单中的词:{segments[0]!r}slug={slug!r}"
(errors if strict else warnings).append(msg)
form = classify_slug_form(segments, slug=slug)
if form == "scope":
if segments[-1] not in scopes:
msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}slug={slug!r}"
(errors if strict else warnings).append(msg)
noun_count = segment_count - 2
elif form == "standard":
platform_match = _match_platform_suffix(segments, platforms)
if platform_match is None:
msg = f"slug 末段应匹配平台白名单:{slug!r}"
(errors if strict else warnings).append(msg)
noun_count = 0
else:
_platform_name, platform_part_count = platform_match
noun_count = segment_count - 1 - platform_part_count
else:
if segment_count >= _MIN_SEGMENTS:
if segments[-1] not in scopes:
platform_match = _match_platform_suffix(segments, platforms)
if platform_match is None:
msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}"
(errors if strict else warnings).append(msg)
noun_count = 0
if noun_count > _MAX_NOUN_SEGMENTS:
warnings.append(
f"slug 中间名词段建议不超过 {_MAX_NOUN_SEGMENTS} 个词(当前 {noun_count}{slug!r}"
)
return errors, warnings

View File

@@ -1,6 +1,6 @@
# skill-template 测试说明(企业数字员工技能)
本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须通过环境变量显式授权,且仅以 ``*.sample`` 或复制后的文件提供范式。
本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite**真实 API / 真实 RPA** 必须显式设置 ``OPENCLAW_TEST_TARGET``,且仅以 ``*.sample`` 或复制后的文件提供范式。
复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。
@@ -16,9 +16,9 @@
| 纯函数 / 业务规则 | `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`后按需运行 |
| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api` 后按需运行 |
| 仿真 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` |
| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` |
| 真实 RPA | `tests/integration/test_real_rpa_contract.py.sample` | 否 | 最高风险,只能手动触发 |
| 桌面宿主 E2E | `tests/desktop/test_desktop_smoke.py.sample` | 否 | 需要 pytest + `jiangchang_desktop_sdk` |
@@ -70,9 +70,11 @@ python tests/run_tests.py test_cli_smoke
|------|------|
| CLI 冒烟import ``cli.app.main`` | ``test_cli_smoke.py`` |
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **CLAW_*/JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.py`` |
| 数据管理元数据 / 字段顺序 | ``test_display_metadata.py`` |
| 标准时间字段Unix 秒 / trigger / 元数据) | ``test_timestamp_columns.py`` |
| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) |
| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` |
| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` |
@@ -84,17 +86,14 @@ python tests/run_tests.py test_cli_smoke
### 1.3 数据隔离(``_support.IsolatedDataRoot``
同时设置(进入同一临时目录 / 用户
进入同一临时目录 / 用户:
| ``CLAW_DATA_ROOT`` | ``JIANGCHANG_DATA_ROOT`` |
|--------------------|---------------------------|
| 同一 ``tempfile.mkdtemp(prefix="skill-test-")`` | 同上 |
| 变量 | 值 |
|------|-----|
| ``JIANGCHANG_DATA_ROOT`` | ``tempfile.mkdtemp(prefix="skill-test-")`` |
| ``JIANGCHANG_USER_ID`` | ``_test`` |
| ``CLAW_USER_ID`` | ``JIANGCHANG_USER_ID`` |
|------------------|-------------------------|
| ``_test`` | ``_test`` |
退出时四个键均恢复(原不存在则 ``pop``),并删除临时目录。
退出时两个键均恢复(原不存在则 ``pop``),并删除临时目录。
### 1.4 产物与密钥
@@ -125,11 +124,11 @@ python tests/run_tests.py test_cli_smoke
---
## 5. 环境变量:测试档位与授权
## 5. 环境变量:测试档位
### 5.1 测试档位(二选一变量名,后者为历史拼写兼容)
### 5.1 测试档位
- ``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
- ``OPENCLAW_TEST_TARGET``
**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``
@@ -139,23 +138,15 @@ python tests/run_tests.py test_cli_smoke
| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) |
| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost |
| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** |
| ``real_api`` | 真实 API另需 ``ALLOW_REAL_API=1`` |
| ``real_rpa`` | 真实 RPA另需 ``ALLOW_REAL_RPA=1`` |
| ``real_api`` | 真实 API显式设 ``OPENCLAW_TEST_TARGET=real_api`` |
| ``real_rpa`` | 真实 RPA显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` |
未设置时等价 ``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``
### 5.2 默认安全策略(``adapter_test_utils``
- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。
- ``assert_no_real_network_env()`` 断言默认未误 ``ALLOW_REAL_*``(用于必跑用例自检)。
- ``assert_no_real_network_env()`` 断言默认未误 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``(用于必跑用例自检)。
---
@@ -170,7 +161,7 @@ python tests/run_tests.py test_cli_smoke
- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。
- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。
- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` / `ALLOW_REAL_*` / `ALLOW_WRITE_ACTIONS` 等**显式开关**;不得默认开启。
- 有真实系统联调需求时,测试应写在 `tests/integration/`,并显式设置 ``OPENCLAW_TEST_TARGET`` ``real_api`` / ``real_rpa``;不得默认开启。
---

View File

@@ -3,10 +3,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 临时目录。
2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT``JIANGCHANG_USER_ID`
退出时恢复原值并删除临时目录,避免污染真实用户数据目录。
"""
from __future__ import annotations
@@ -24,9 +22,7 @@ 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",
)
@@ -39,7 +35,7 @@ def get_skill_root() -> str:
return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.0.14"):
def platform_kit_version_patch(version: str = "1.0.17"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch
@@ -53,11 +49,11 @@ class IsolatedDataRoot:
"""
在独立临时目录下模拟数据根,避免污染本机默认数据目录。
进入上下文时(同一套隔离值写入 CLAW_* 与 JIANGCHANG_*
- CLAW_DATA_ROOT / JIANGCHANG_DATA_ROOT -> 同一 tempfile.mkdtemp(prefix='skill-test-')
- CLAW_USER_ID / JIANGCHANG_USER_ID -> _test与业务默认用户区分
进入上下文时:
- JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-')
- JIANGCHANG_USER_ID -> _test与业务默认用户区分
退出时:个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
退出时:个变量均恢复为进入前状态(若原先未设置则 pop并删除临时目录。
"""
def __init__(self, user_id: str = "_test") -> None:
@@ -69,9 +65,7 @@ class IsolatedDataRoot:
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

View File

@@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
"""
企业技能对接外部系统ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA
测试侧共用的 **profile / 授权 / 禁止真实外呼** 工具。
测试侧共用的 **profile / 禁止真实外呼** 工具。
- 仅标准库;不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并配合环境变量显式授权
- 仅标准库 + jiangchang_skill_core.config;不发起 HTTP不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``
"""
from __future__ import annotations
import os
from typing import Any, Final
# 允许的 OPENCLAW_TEST_TARGET / OPENCLOW_TEST_TARGET 取值(后者为历史拼写兼容)
from jiangchang_skill_core import config
ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
{
"unit",
@@ -25,12 +25,12 @@ ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
def _raw_test_target_env() -> str:
return (os.environ.get("OPENCLAW_TEST_TARGET") or os.environ.get("OPENCLOW_TEST_TARGET") or "").strip()
return (config.get("OPENCLAW_TEST_TARGET") or "").strip()
def get_test_target() -> str:
"""
读取 ``OPENCLAW_TEST_TARGET`` ``OPENCLOW_TEST_TARGET``(兼容旧拼写)。
读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。
未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。
"""
@@ -46,18 +46,15 @@ def get_test_target() -> str:
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)`` 组合检查)。
判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试
"""
if profile == "real_api":
return os.environ.get("ALLOW_REAL_API", "") == "1"
if profile == "real_rpa":
return os.environ.get("ALLOW_REAL_RPA", "") == "1"
target = get_test_target()
if profile in ("mock", "simulator_api", "simulator_rpa"):
return True
if profile == "real_api":
return target == "real_api"
if profile == "real_rpa":
return target == "real_rpa"
raise ValueError(f"unknown profile for real_access_allowed: {profile!r}")
@@ -67,42 +64,19 @@ def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]:
策略摘要:
- ``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``。
- ``simulator_api`` / ``simulator_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``real_api`` / ``real_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``write`` 参数保留以兼容 integration 样例签名;档位一致即允许写操作测试
"""
_ = write
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)")
if profile in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
if target != profile:
return (True, f"OPENCLAW_TEST_TARGET must be {profile} (got {target!r})")
return (False, "")
raise ValueError(f"unknown profile: {profile!r}")
@@ -134,9 +108,11 @@ class FakeAdapter:
def assert_no_real_network_env() -> None:
"""
断言当前进程未显式打开「真实外呼」授权开关(默认安全 CI / 本地开发)。
断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。
若需在受控环境跑真实集成,请显式设置 ``ALLOW_REAL_*`` 并使用 ``tests/integration/*.sample``。
若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``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"
target = get_test_target()
assert target not in ("real_api", "real_rpa"), (
f"OPENCLAW_TEST_TARGET must not be {target!r} in default unit tests"
)

View File

@@ -11,7 +11,7 @@
|------|------------------|------------------------|
| 目的 | 默认可**升级**为根目录 `test_*.py`**业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 |
| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API(授权)、真实 RPA最高风险 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API、真实 RPA最高风险 |
**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`
@@ -21,20 +21,15 @@
|------|------------------------------|----------|
| 本地 / 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``**仅手动触发** |
| **真实 API** | ``real_api`` | **仅手动触发** |
| **真实 RPA**(最高风险) | ``real_rpa`` | **仅手动触发** |
## 环境变量(与 ``adapter_test_utils`` 一致)
- **目标档位**(二选一键名,后者为历史拼写):``OPENCLAW_TEST_TARGET`` 或 ``OPENCLOW_TEST_TARGET``
- **目标档位**``OPENCLAW_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。

View File

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

View File

@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_rpa_contract.py需 OPENCLAW_TEST_TARGET=real_rpa 且 ALLOW_REAL_RPA=1
# 样例:复制为 test_real_rpa_contract.py需 OPENCLAW_TEST_TARGET=real_rpa。
"""
真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。
默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。
任何写操作必须 ``should_skip_profile('real_rpa', write=True)`` 且 ``ALLOW_WRITE_ACTIONS=1``
写操作测试同样须显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` 后手动触发
"""
from __future__ import annotations

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import os
import unittest
from jiangchang_skill_core import config
from adapter_test_utils import (
ALLOWED_TEST_TARGETS,
FakeAdapter,
@@ -24,34 +26,22 @@ def _restore_env(saved: dict[str, str | None]) -> None:
os.environ.pop(k, None)
else:
os.environ[k] = v
config.reset_cache()
_ENV_KEYS = (
"OPENCLAW_TEST_TARGET",
"OPENCLOW_TEST_TARGET",
"ALLOW_REAL_API",
"ALLOW_REAL_RPA",
"ALLOW_WRITE_ACTIONS",
)
_ENV_KEYS = ("OPENCLAW_TEST_TARGET",)
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 tearDown(self) -> None:
config.reset_cache()
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)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
finally:
_restore_env(saved)
@@ -62,6 +52,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
for k in _ENV_KEYS:
os.environ.pop(k, None)
os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target"
config.reset_cache()
with self.assertRaises(ValueError):
get_test_target()
finally:
@@ -74,6 +65,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api"
config.reset_cache()
skip, _ = should_skip_profile("simulator_api")
self.assertFalse(skip)
finally:
@@ -84,6 +76,7 @@ class TestAdapterProfilePolicy(unittest.TestCase):
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
skip, reason = should_skip_profile(prof)
@@ -93,46 +86,23 @@ class TestAdapterProfilePolicy(unittest.TestCase):
finally:
_restore_env(saved)
def test_real_api_requires_allow_flag(self) -> None:
def test_real_api_runs_when_target_matches(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)
config.reset_cache()
skip, _ = should_skip_profile("real_api", write=True)
self.assertFalse(skip)
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:
def test_real_rpa_runs_when_target_matches(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)
config.reset_cache()
skip, _ = should_skip_profile("real_rpa", write=True)
self.assertFalse(skip)
finally:
_restore_env(saved)
@@ -146,10 +116,10 @@ class TestAdapterProfilePolicy(unittest.TestCase):
FakeAdapter("unauthorized").call({})
def test_assert_no_real_network_env(self) -> None:
saved = _save_env(("ALLOW_REAL_API", "ALLOW_REAL_RPA"))
saved = _save_env(_ENV_KEYS)
try:
os.environ.pop("ALLOW_REAL_API", None)
os.environ.pop("ALLOW_REAL_RPA", None)
os.environ.pop("OPENCLAW_TEST_TARGET", None)
config.reset_cache()
assert_no_real_network_env()
finally:
_restore_env(saved)

View File

@@ -10,12 +10,20 @@ 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
from db.connection import get_conn, init_db
init_db()
init_db() # 幂等
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'")
self.assertEqual(cur.fetchone()[0], "任务日志")
finally:
conn.close()
new_id = tlr.save_task_log(
task_type="demo_task",
target_id="tgt-1",

View File

@@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
"""数据管理展示元数据幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。"""
from __future__ import annotations
import sqlite3
import unittest
from _support import IsolatedDataRoot, get_skill_root
class TestDisplayMetadata(unittest.TestCase):
def test_init_db_metadata_idempotent(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 1)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 10)
finally:
conn.close()
def test_task_logs_has_chinese_table_and_column_names(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'"
)
self.assertEqual(cur.fetchone()[0], "任务日志")
cur.execute(
"""
SELECT column_name, display_name
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
ORDER BY column_name
"""
)
by_name = dict(cur.fetchall())
self.assertEqual(by_name["status"], "状态")
self.assertEqual(by_name["error_msg"], "错误信息")
self.assertEqual(by_name["created_at"], "创建时间")
finally:
conn.close()
def test_task_logs_physical_order_matches_pragma_cid(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from db.display_metadata import TASK_LOGS_COLUMN_METADATA, list_physical_column_names
from db.connection import get_conn
init_db()
conn = get_conn()
try:
cur = conn.cursor()
physical = list_physical_column_names(cur, "task_logs")
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
self.assertEqual(
physical,
expected,
)
cur.execute("PRAGMA table_info('task_logs')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_init_db_sets_task_logs_display_order_null(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT column_name, display_order
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
"""
)
for column_name, display_order in cur.fetchall():
self.assertIsNone(
display_order,
msg=f"{column_name} display_order should be NULL",
)
finally:
conn.close()
def test_reinit_clears_stale_display_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
conn.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT display_order FROM _jiangchang_columns
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
)
self.assertIsNone(cur.fetchone()[0])
finally:
conn.close()
def test_legacy_display_order_does_not_change_pragma_physical_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 1
WHERE table_name = 'task_logs' AND column_name = 'updated_at'
"""
)
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
)
conn.commit()
physical = list_physical_column_names(cur, "task_logs")
self.assertEqual(physical[0], "id")
self.assertEqual(physical[-1], "updated_at")
finally:
conn.close()
def test_metadata_tables_are_not_user_tables(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import iter_user_tables
init_db()
conn = get_conn()
try:
tables = list(iter_user_tables(conn.cursor()))
self.assertEqual(tables, ["task_logs"])
finally:
conn.close()
def test_template_database_validation_passes(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
report = validate_template_database(conn)
self.assertTrue(report.ok, msg="; ".join(report.errors))
finally:
conn.close()
def test_validate_missing_both_metadata_tables(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)")
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
finally:
conn.close()
def test_validate_missing_jiangchang_tables_only(self) -> None:
from db.display_metadata import _JIANGCHANG_COLUMNS_DDL
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_COLUMNS_DDL)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
self.assertNotIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
finally:
conn.close()
def test_validate_missing_jiangchang_columns_only(self) -> None:
from db.display_metadata import _JIANGCHANG_TABLES_DDL
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_TABLES_DDL)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
self.assertNotIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
finally:
conn.close()
def test_missing_table_metadata_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)")
conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'")
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items" in err for err in report.errors))
finally:
conn.close()
def test_missing_column_metadata_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = 'task_logs' AND column_name = 'status'"
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("task_logs.status" in err for err in report.errors))
finally:
conn.close()
def test_metadata_reference_to_missing_column_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('task_logs', 'not_exists', '不存在', 1, 1, 1)
"""
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("not_exists" in err for err in report.errors))
finally:
conn.close()
def test_ordering_test_table_not_alphabetized(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE ordering_test (
z_field TEXT,
id INTEGER PRIMARY KEY,
a_field TEXT,
created_at INTEGER,
updated_at INTEGER
);
"""
)
from db.display_metadata import list_physical_column_names
physical = list_physical_column_names(conn.cursor(), "ordering_test")
self.assertEqual(physical, ["z_field", "id", "a_field", "created_at", "updated_at"])
finally:
conn.close()
def test_skill_frontmatter_validation_for_template(self) -> None:
from db.display_metadata_validator import validate_skill_frontmatter
report = validate_skill_frontmatter(get_skill_root(), allow_template_placeholders=True)
self.assertTrue(report.ok, msg="; ".join(report.errors))
def test_skill_frontmatter_rejects_english_placeholder_name(self) -> None:
import os
import tempfile
from db.display_metadata_validator import validate_skill_frontmatter
with tempfile.TemporaryDirectory() as tmp:
skill_md = """---
name: My Skill
description: demo
metadata:
openclaw:
slug: query-demo-info
---
"""
with open(os.path.join(tmp, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(skill_md)
report = validate_skill_frontmatter(tmp, allow_template_placeholders=False)
self.assertFalse(report.ok)
self.assertTrue(any("name" in err for err in report.errors))
def test_column_order_warning_for_created_at_in_middle(self) -> None:
from db.display_metadata_validator import column_order_warnings
warnings = column_order_warnings(
"bad_order",
["created_at", "id", "title", "updated_at"],
)
self.assertTrue(any("id" in item for item in warnings))
if __name__ == "__main__":
unittest.main()

View File

@@ -141,12 +141,21 @@ class TestDocsStandards(unittest.TestCase):
msg="SKILL.md must state README.md is user-facing documentation",
)
def test_naming_md_exists_and_covers_standards(self) -> None:
text = self._read("development/NAMING.md")
self.assertIn("verb-noun-platform", text)
self.assertIn("48", text)
self.assertTrue("LEGACY" in text or "历史" in text)
self.assertTrue("verbs.txt" in text or "动词白名单" in text)
self.assertTrue("platforms.txt" in text or "平台白名单" in text)
self.assertTrue("仅适用于新创建" in text or "新技能" in text)
def test_release_workflow_uses_main_channel(self) -> None:
text = self._read(".github/workflows/release_skill.yaml")
uses_line = _extract_workflow_uses_line(text)
self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line")
self.assertIn(
"admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
"client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
uses_line,
)
self.assertRegex(
@@ -163,21 +172,99 @@ class TestDocsStandards(unittest.TestCase):
self.assertIn("--no-sandbox", text)
self.assertIn("--disable-blink-features=AutomationControlled", text)
self.assertIn("RpaVideoSession", text)
self.assertIn("1.0.14", text)
self.assertIn("1.0.17", text)
def test_adapter_md_covers_four_tiers_and_allow_flags(self) -> None:
def test_rpa_md_forbids_rpa_helpers_import(self) -> None:
text = self._read("development/RPA.md")
self.assertIn("rpa_helpers", text)
self.assertTrue(
"不要 import" in text or "不要" in text and "import" in text,
msg="RPA.md must document forbidding rpa_helpers import",
)
def test_rpa_md_covers_simulator_playwright_layering(self) -> None:
text = self._read("development/RPA.md")
self.assertTrue(
"simulator_playwright" in text
or ("薄 adapter" in text and "playwright.py" in text),
msg="RPA.md must describe thin adapter + *_playwright.py layering",
)
def test_rpa_md_covers_industry_simulator_portal(self) -> None:
text = self._read("development/RPA.md")
self.assertTrue(
any(marker in text for marker in ("jc2009", "行业仿真", "门户")),
msg="RPA.md must mention jc2009 / industry simulator / portal gate",
)
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower())
self.assertTrue(
"account-manager" in text.lower() or "pick-web" in text or "pick_web_account" in text,
msg="simulator_browser_rpa README must mention async and account-manager",
)
def test_development_md_documents_scaffold_and_git_cleanup(self) -> None:
text = self._read("development/DEVELOPMENT.md")
self.assertTrue(
"scaffold_skill" in text or "scaffold_skill.ps1" in text,
msg="DEVELOPMENT.md must document scaffold_skill",
)
self.assertTrue(
("Remove-Item" in text and ".git" in text)
or ("删除" in text and ".git" in text),
msg="DEVELOPMENT.md must document deleting .git",
)
def test_naming_md_copy_flow_documents_scaffold_or_git_cleanup(self) -> None:
text = self._read("development/NAMING.md")
self.assertTrue(
"scaffold" in text.lower() or ("清理" in text and ".git" in text),
msg="NAMING.md §9 must mention scaffold or Git cleanup",
)
def test_scaffold_ps1_exists(self) -> None:
path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist")
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
forbidden = (
"download-settlement-report-amazon",
"amazon_sim",
"#amz-email",
)
guarded_docs = (
"development/RPA.md",
"development/ADAPTER.md",
"development/DEVELOPMENT.md",
"development/CONFIG.md",
"development/TESTING.md",
"examples/README.md",
"examples/simulator_browser_rpa/README.md",
)
for rel in guarded_docs:
text = self._read(rel)
for fragment in forbidden:
self.assertNotIn(
fragment,
text,
msg=f"{rel} must not reference Amazon implementation {fragment!r}",
)
def test_adapter_md_covers_four_tiers_and_config_get(self) -> None:
text = self._read("development/ADAPTER.md")
for marker in (
"mock",
"simulator_rpa",
"real_api",
"real_rpa",
"ALLOW_REAL_API",
"ALLOW_REAL_RPA",
"ALLOW_WRITE_ACTIONS",
"config.get",
"sibling_bridge",
):
self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}")
self.assertNotIn("ALLOW_REAL_API", text)
self.assertNotIn("ALLOW_WRITE_ACTIONS", text)
def test_cli_md_mentions_shared_python_runtime(self) -> None:
text = self._read("references/CLI.md")

View File

@@ -99,7 +99,7 @@ class TestHealthRuntimeDiagnostics(unittest.TestCase):
with IsolatedDataRoot(user_id="_health_music_warn"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
media_root = Path(os.environ["CLAW_DATA_ROOT"]) / "shared" / "media-assets"
media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets"
ffmpeg_path = media_root / "tools" / "ffmpeg.exe"
ffmpeg_path.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_path.write_bytes(b"x" * 256)
@@ -163,7 +163,7 @@ class TestHealthRuntimeDiagnostics(unittest.TestCase):
class TestHealthRuntimeWithFakeMedia(unittest.TestCase):
def test_probe_counts_mp3_without_download(self) -> None:
with IsolatedDataRoot(user_id="_health_media"):
media_root = Path(os.environ["CLAW_DATA_ROOT"]) / "shared" / "media-assets"
media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets"
music_dir = media_root / "music"
music_dir.mkdir(parents=True)
(music_dir / "demo.mp3").write_bytes(b"\x00" * 1024)

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""禁止在模板主骨架 scripts/ 中 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from _support import get_skill_root
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
scripts_dir = os.path.join(get_skill_root(), "scripts")
violations: list[str] = []
for root, _dirs, files in os.walk(scripts_dir):
for name in files:
if not name.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(root, name), scripts_dir)
text = open(os.path.join(root, name), encoding="utf-8").read()
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{rel}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports in scripts/:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -87,12 +87,12 @@ class TestPlatformImportSource(unittest.TestCase):
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.0.14")
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.0.17")
md_path = os.path.join(get_skill_root(), "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
self.assertEqual(_parse_platform_kit_min_version(md), "1.0.14")
self.assertEqual(_parse_platform_kit_min_version(md), "1.0.17")
req_path = os.path.join(get_skill_root(), "requirements.txt")
with open(req_path, encoding="utf-8") as f:

View File

@@ -7,7 +7,11 @@ import unittest
from _support import IsolatedDataRoot, get_skill_root
_FAKE_CLAW_ROOT = r"D:\jiangchang-data"
_FAKE_REAL_ROOT = r"D:\jiangchang-data"
def _legacy_env_key(suffix: str) -> str:
return "CL" + "AW_" + suffix
class TestRuntimePaths(unittest.TestCase):
@@ -35,12 +39,14 @@ class TestRuntimePaths(unittest.TestCase):
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")}
def test_isolation_overrides_prefixed_real_data_root(self) -> None:
"""先设假数据根再进入隔离get_db_path 必须落在 tempdir。"""
saved = {
k: os.environ.get(k)
for k in ("JIANGCHANG_DATA_ROOT", "JIANGCHANG_USER_ID")
}
try:
os.environ["CLAW_DATA_ROOT"] = _FAKE_CLAW_ROOT
os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_CLAW_ROOT
os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_REAL_ROOT
with IsolatedDataRoot() as tmp:
import importlib
@@ -51,8 +57,11 @@ class TestRuntimePaths(unittest.TestCase):
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}")
norm_fake = os.path.normcase(os.path.normpath(_FAKE_REAL_ROOT))
self.assertFalse(
norm_db.startswith(norm_fake),
msg=f"db_path must not be under fake root: {db_path!r}",
)
finally:
for k, v in saved.items():
if v is None:
@@ -60,6 +69,19 @@ class TestRuntimePaths(unittest.TestCase):
else:
os.environ[k] = v
def test_legacy_env_vars_do_not_override_canonical(self) -> None:
with IsolatedDataRoot() as tmp:
os.environ[_legacy_env_key("DATA_ROOT")] = _FAKE_REAL_ROOT
os.environ[_legacy_env_key("USER_ID")] = "legacy-user"
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
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("\\", "/"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""skill-template 脚手架与 Git 防串库守护。"""
from __future__ import annotations
import os
import unittest
from _support import get_skill_root
TEMPLATE_MARKER = ".openclaw-skill-template"
TEMPLATE_SLUG = "your-skill-slug"
def _read_skill_slug() -> str:
constants_path = os.path.join(get_skill_root(), "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
for line in f:
if line.strip().startswith("SKILL_SLUG"):
_, _, value = line.partition("=")
return value.strip().strip('"').strip("'")
return ""
class TestScaffoldGuard(unittest.TestCase):
def test_template_repo_has_marker(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG:
self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertTrue(
os.path.isfile(marker),
msg=f"skill-template 源仓库根目录必须存在 {TEMPLATE_MARKER}",
)
def test_business_skill_must_not_keep_template_marker(self) -> None:
slug = _read_skill_slug()
if slug == TEMPLATE_SLUG:
self.skipTest("当前仍为模板占位 slug由 test_template_repo_has_marker 守护")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertFalse(
os.path.isfile(marker),
msg=(
"业务技能不得保留 .openclaw-skill-template"
"请使用 tools/scaffold_skill 或删除标记文件并检查是否误复制了模板 .git"
),
)
def test_development_md_documents_git_cleanup(self) -> None:
path = os.path.join(get_skill_root(), "development", "DEVELOPMENT.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn(".git", text)
self.assertTrue(
"scaffold_skill" in text or "scaffold" in text.lower(),
msg="DEVELOPMENT.md must document scaffold_skill",
)
self.assertTrue(
"remote" in text.lower() or "git remote" in text.lower(),
msg="DEVELOPMENT.md must mention git remote self-check",
)
if __name__ == "__main__":
unittest.main()

87
tests/test_slug_naming.py Normal file
View File

@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""slug 语义校验verb-noun-platform"""
from __future__ import annotations
import unittest
from util.slug_naming import (
LEGACY_EXEMPT_SLUGS,
classify_slug_form,
parse_slug_segments,
validate_slug_semantics,
)
class TestSlugNaming(unittest.TestCase):
def test_parse_slug_segments(self) -> None:
self.assertEqual(parse_slug_segments("download-settlement-report-amazon"), [
"download",
"settlement",
"report",
"amazon",
])
def test_classify_standard(self) -> None:
segments = parse_slug_segments("download-settlement-report-amazon")
self.assertEqual(classify_slug_form(segments, slug="download-settlement-report-amazon"), "standard")
def test_classify_scope(self) -> None:
segments = parse_slug_segments("generate-article-all")
self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope")
def test_classify_legacy_exempt(self) -> None:
segments = parse_slug_segments("your-skill-slug")
self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt")
def test_valid_hyphenated_platform_slug(self) -> None:
slug = "fill-export-declaration-single-window"
segments = parse_slug_segments(slug)
self.assertEqual(classify_slug_form(segments, slug=slug), "standard")
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
def test_valid_standard_slugs(self) -> None:
for slug in (
"download-settlement-report-amazon",
"add-product-shopee",
"generate-article-all",
"your-skill-slug",
"account-manager",
):
with self.subTest(slug=slug):
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
def test_invalid_slugs(self) -> None:
cases = (
"amazon-download-settlement-report",
"enter-cross-border-sales-receipt-voucher-kingdee",
"reconcile-finance",
"download-settlement-report-unknownplat",
"foobar-settlement-report-amazon",
)
for slug in cases:
with self.subTest(slug=slug):
errors, _warnings = validate_slug_semantics(slug)
self.assertTrue(errors, msg=f"expected errors for {slug!r}")
def test_length_boundary_48_passes(self) -> None:
slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon"
self.assertEqual(len(slug), 48)
errors, _warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [])
def test_length_boundary_49_fails(self) -> None:
slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon"
self.assertEqual(len(slug), 49)
errors, _warnings = validate_slug_semantics(slug)
self.assertTrue(errors)
self.assertTrue(any("48" in err for err in errors))
def test_legacy_exempt_set(self) -> None:
self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS)
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)
if __name__ == "__main__":
unittest.main()

View File

@@ -34,7 +34,7 @@ FORBIDDEN_PHRASES = (
POSITIVE_MARKERS = (
"jiangchang-platform-kit",
"1.0.14",
"1.0.17",
"共享 runtime",
)

View File

@@ -0,0 +1,336 @@
# -*- coding: utf-8 -*-
"""标准时间字段Unix 秒级时间戳、默认值、trigger 与元数据。"""
from __future__ import annotations
import sqlite3
import time
import unittest
from _support import IsolatedDataRoot
class TestTimestampColumns(unittest.TestCase):
def test_init_db_creates_integer_timestamp_columns_with_defaults(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
from db.display_metadata_validator import _table_column_info
init_db()
conn = get_conn()
try:
cur = conn.cursor()
physical = list_physical_column_names(cur, "task_logs")
self.assertEqual(physical[-2:], ["created_at", "updated_at"])
info = _table_column_info(cur, "task_logs")
for column_name in ("created_at", "updated_at"):
column = info[column_name]
self.assertIn("INT", str(column["type"]).upper())
self.assertTrue(str(column["dflt_value"]).lower().find("unixepoch") >= 0)
finally:
conn.close()
def test_insert_without_timestamps_auto_generates_unix_seconds(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
before = int(time.time())
cur.execute(
"INSERT INTO task_logs (task_type, status) VALUES ('auto-ts', 'success')"
)
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM task_logs ORDER BY id DESC LIMIT 1"
)
created_at, updated_at = cur.fetchone()
after = int(time.time())
self.assertIsInstance(created_at, int)
self.assertIsInstance(updated_at, int)
self.assertGreaterEqual(created_at, before)
self.assertLessEqual(created_at, after)
self.assertEqual(created_at, updated_at)
finally:
conn.close()
def test_update_business_field_keeps_created_at_and_refreshes_updated_at(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO task_logs (task_type, status, created_at, updated_at)
VALUES ('update-ts', 'success', 1000, 1000)
"""
)
log_id = int(cur.lastrowid)
conn.commit()
cur.execute("UPDATE task_logs SET status = 'failed' WHERE id = ?", (log_id,))
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE id = ?",
(log_id,),
)
created_at, updated_at = cur.fetchone()
self.assertEqual(created_at, 1000)
self.assertGreater(updated_at, 1000)
finally:
conn.close()
def test_reinit_does_not_rewrite_existing_timestamps(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO task_logs (task_type, status, created_at, updated_at)
VALUES ('stable-ts', 'success', 1234567890, 1234567891)
"""
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE task_type = 'stable-ts'"
)
self.assertEqual(cur.fetchone(), (1234567890, 1234567891))
finally:
conn.close()
def test_timestamp_metadata_contract(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import DATETIME_UNIX_SECONDS
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT column_name, display_name, display_type, editable, display_order
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
AND column_name IN ('created_at', 'updated_at')
"""
)
by_name = {
name: (display_name, display_type, editable, display_order)
for name, display_name, display_type, editable, display_order in cur.fetchall()
}
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(by_name["updated_at"][0], "更新时间")
self.assertEqual(by_name["created_at"][1], DATETIME_UNIX_SECONDS)
self.assertEqual(by_name["updated_at"][1], DATETIME_UNIX_SECONDS)
self.assertEqual(int(by_name["created_at"][2]), 0)
self.assertEqual(int(by_name["updated_at"][2]), 0)
self.assertIsNone(by_name["created_at"][3])
self.assertIsNone(by_name["updated_at"][3])
finally:
conn.close()
def test_metadata_upsert_keeps_timestamp_display_type(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import DATETIME_UNIX_SECONDS
init_db()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT display_type FROM _jiangchang_columns
WHERE table_name = 'task_logs' AND column_name = 'created_at'
"""
)
self.assertEqual(cur.fetchone()[0], DATETIME_UNIX_SECONDS)
finally:
conn.close()
def test_repository_insert_uses_database_timestamps(self) -> None:
with IsolatedDataRoot():
from db import task_logs_repository as tlr
from db.connection import get_conn, init_db
init_db()
before = int(time.time())
new_id = tlr.save_task_log(task_type="repo-ts", status="success")
after = int(time.time())
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE id = ?",
(new_id,),
)
created_at, updated_at = cur.fetchone()
self.assertGreaterEqual(int(created_at), before)
self.assertLessEqual(int(created_at), after)
self.assertEqual(created_at, updated_at)
finally:
conn.close()
def test_validator_rejects_missing_display_type(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
conn.execute(
"UPDATE _jiangchang_columns SET display_type = NULL WHERE table_name = 'task_logs' AND column_name = 'created_at'"
)
conn.commit()
report = validate_template_database(conn)
self.assertFalse(report.ok)
self.assertTrue(any("display_type" in err for err in report.errors))
finally:
conn.close()
def test_validator_rejects_wrong_timestamp_type(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import init_display_metadata
from db.display_metadata_validator import validate_display_metadata
conn.executescript(
"""
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY,
title TEXT,
created_at TEXT
);
"""
)
init_display_metadata(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly)
VALUES ('demo_items', '演示', 1, 0)
"""
)
for column_name, display_name in (
("id", "编号"),
("title", "标题"),
("created_at", "创建时间"),
):
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable, display_type)
VALUES ('demo_items', ?, ?, 1, 0, 0, 'datetime_unix_seconds')
""",
(column_name, display_name),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("created_at 字段类型应为 INTEGER" in err for err in report.errors))
finally:
conn.close()
def test_validator_rejects_editable_timestamp(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
conn.execute(
"UPDATE _jiangchang_columns SET editable = 1 WHERE table_name = 'task_logs' AND column_name = 'updated_at'"
)
conn.commit()
report = validate_template_database(conn)
self.assertFalse(report.ok)
self.assertTrue(any("editable 应为 0" in err for err in report.errors))
finally:
conn.close()
def test_validator_does_not_flag_amount_as_timestamp(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import init_display_metadata
from db.display_metadata_validator import validate_display_metadata
conn.executescript(
"""
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY,
amount INTEGER,
count INTEGER
);
"""
)
init_display_metadata(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly)
VALUES ('demo_items', '演示', 1, 0)
"""
)
for column_name, display_name in (
("id", "编号"),
("amount", "数量"),
("count", "计数"),
):
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', ?, ?, 1, 1, 1)
""",
(column_name, display_name),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(any("amount" in err and "display_type" in err for err in report.errors))
self.assertFalse(any("count" in err and "display_type" in err for err in report.errors))
finally:
conn.close()
def test_trigger_creation_is_idempotent(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.timestamp_columns import TASK_LOGS_UPDATED_AT_TRIGGER, has_updated_at_trigger
init_db()
init_db()
conn = get_conn()
try:
self.assertTrue(has_updated_at_trigger(conn.cursor(), "task_logs"))
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?",
(TASK_LOGS_UPDATED_AT_TRIGGER,),
)
self.assertEqual(int(cur.fetchone()[0]), 1)
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

47
tools/README.md Normal file
View File

@@ -0,0 +1,47 @@
# skill-template 工具
## 推荐:脚手架创建新技能
**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。
Windows 首选:
```powershell
cd D:\OpenClaw\client-commons\skill-template
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
```
跨平台Python 3.10+
```bash
python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill
```
脚本行为:
- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致)
- **排除**`.git``.pytest_cache``__pycache__``.env``.env.local``*.pyc`
- 删除目标中的 `.openclaw-skill-template` 标记
- **不**自动 `git init`;打印后续 Git 与测试命令
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
## 手工复制后的补救
若已整目录复制,必须先清理再初始化:
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
确认 `origin` **不是** skill-template 的远端 URL。
## 模板标记
源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。

107
tools/scaffold_skill.ps1 Normal file
View File

@@ -0,0 +1,107 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Scaffold a new skill directory from skill-template (excludes .git).
.EXAMPLE
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Destination,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Test-KebabSlug([string]$Value) {
return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$'
}
function Write-ScaffoldNextSteps([string]$TargetPath) {
Write-Host ""
Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green
Write-Host ("目标目录: " + $TargetPath)
Write-Host ""
Write-Host "请在本机继续执行(未自动 git init避免误操作" -ForegroundColor Yellow
Write-Host (' cd "' + $TargetPath + '"')
Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue"
Write-Host " git init"
Write-Host " git remote add origin <你的新技能仓库 URL>"
Write-Host " git remote -v"
Write-Host " python tests/run_tests.py -v"
Write-Host ""
}
$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$TargetPath = $Destination
if (-not (Test-KebabSlug $Slug)) {
throw "Slug must be kebab-case. Got: $Slug"
}
$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/'))
if ($leaf -ne $Slug) {
throw "Destination leaf '$leaf' must match -Slug '$Slug'."
}
$parent = Split-Path -Parent $TargetPath
if (-not $parent -or -not (Test-Path -LiteralPath $parent)) {
throw "Parent directory does not exist: $parent"
}
$gitPath = Join-Path $TargetPath ".git"
if (Test-Path -LiteralPath $gitPath) {
throw "Target already has .git at $gitPath . Remove it first or use an empty destination."
}
$targetExists = Test-Path -LiteralPath $TargetPath
if ($targetExists) {
$entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue)
if ($entries.Count -gt 0 -and -not $Force) {
throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory."
}
}
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
$excludeFiles = @(".env", ".env.local")
function Copy-TemplateTree {
param(
[string]$Source,
[string]$Dest
)
if (-not (Test-Path -LiteralPath $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
Get-ChildItem -LiteralPath $Source -Force | ForEach-Object {
$name = $_.Name
if ($excludeDirs -contains $name) { return }
if ($excludeFiles -contains $name) { return }
if ($name -like "*.pyc") { return }
$srcPath = $_.FullName
$dstPath = Join-Path $Dest $name
if ($_.PSIsContainer) {
Copy-TemplateTree -Source $srcPath -Dest $dstPath
}
else {
Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force
}
}
}
Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan
Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath
$marker = Join-Path $TargetPath ".openclaw-skill-template"
if (Test-Path -LiteralPath $marker) {
Remove-Item -LiteralPath $marker -Force
}
Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path

106
tools/scaffold_skill.py Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def _ignore_copy(_dir: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
for name in names:
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
ignored.add(name)
elif name.endswith(".pyc"):
ignored.add(name)
return ignored
def _print_next_steps(target: Path) -> None:
print()
print("=== 脚手架复制完成 ===")
print(f"目标目录: {target}")
print()
print("请在本机继续执行(未自动 git init避免误操作")
print(f' cd "{target}"')
print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删")
print(" git init")
print(" git remote add origin <你的新技能仓库 URL>")
print(" git remote -v # 确认 remote 不是 skill-template")
print(" python tests/run_tests.py -v")
print()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录")
parser.add_argument("--slug", required=True, help="kebab-case slug须与目标目录名一致")
parser.add_argument("--destination", required=True, help="新技能完整目标路径")
parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git")
args = parser.parse_args(argv)
slug = args.slug.strip()
if not KEBAB_SLUG.match(slug):
print(f"ERROR: Slug 必须是 kebab-case当前: {slug}", file=sys.stderr)
return 1
target = Path(args.destination).resolve()
if target.name != slug:
print(
f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。",
file=sys.stderr,
)
return 1
parent = target.parent
if not parent.is_dir():
print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr)
return 1
if (target / ".git").exists():
print(
f"ERROR: 目标已存在 .git请先删除: {target / '.git'}",
file=sys.stderr,
)
return 1
template_root = Path(__file__).resolve().parent.parent
if target.exists():
entries = list(target.iterdir())
if entries and not args.force:
print(
f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。",
file=sys.stderr,
)
return 1
target.mkdir(parents=True, exist_ok=True)
print(f"复制模板: {template_root} -> {target}")
for item in template_root.iterdir():
name = item.name
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
continue
if name.endswith(".pyc"):
continue
dest = target / name
if item.is_dir():
shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True)
else:
shutil.copy2(item, dest)
marker = target / ".openclaw-skill-template"
if marker.is_file():
marker.unlink()
_print_next_steps(target)
return 0
if __name__ == "__main__":
raise SystemExit(main())