Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3bd8faf87 | |||
| 30803a0834 | |||
| f3f7a9b9e5 |
2
.github/workflows/release_skill.yaml
vendored
2
.github/workflows/release_skill.yaml
vendored
@@ -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:
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
## 目录骨架
|
||||
|
||||
@@ -74,8 +64,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 +77,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
|
||||
|
||||
|
||||
@@ -215,6 +215,28 @@ scripts/
|
||||
|
||||
`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`。
|
||||
|
||||
### 3.4 发布打包约束(PyArmor 与编码)
|
||||
|
||||
release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**:
|
||||
|
||||
**单文件行数(仅 `scripts/**/*.py`)**
|
||||
|
||||
- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**(`>= 1000` 可能在 CI 加密阶段失败)。
|
||||
- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。
|
||||
- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。
|
||||
- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。
|
||||
|
||||
`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。
|
||||
|
||||
**文本编码(UTF-8 without BOM)**
|
||||
|
||||
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**。
|
||||
- Python 文件**绝对不能**带 UTF-8 BOM;否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`。
|
||||
- Windows 编辑器容易保存成 UTF-8 BOM,提交前须确认文件开头无 `U+FEFF`。
|
||||
- 不要只写「UTF-8」,必须明确 **UTF-8 without BOM**。
|
||||
|
||||
本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。
|
||||
|
||||
## 4. 开发一个新 skill 的标准步骤
|
||||
|
||||
下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。
|
||||
@@ -533,7 +555,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
|
||||
```
|
||||
|
||||
约定如下:
|
||||
@@ -666,6 +688,8 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam
|
||||
- [ ] `release.ps1` 存在
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
- [ ] `python tests/run_tests.py -v` 全部通过
|
||||
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)
|
||||
- [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`)
|
||||
- [ ] 没有新增默认必跑测试访问真实网络或浏览器
|
||||
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
|
||||
5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
|
||||
6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
|
||||
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径与诊断约定
|
||||
7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
|
||||
|
||||
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
|
||||
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。
|
||||
|
||||
@@ -108,6 +108,17 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
|
||||
|
||||
## 编码与输出
|
||||
|
||||
- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**(不要只写「UTF-8」)。
|
||||
- Python 文件**绝对不能**带 UTF-8 BOM;否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`。
|
||||
- Windows 编辑器容易保存成 UTF-8 BOM;提交前确认文件开头无 `U+FEFF`。
|
||||
- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装
|
||||
- 机读输出优先单行 JSON
|
||||
- 错误前缀建议统一 `ERROR:`
|
||||
|
||||
## 发布打包约束(`scripts/**/*.py`)
|
||||
|
||||
release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式:
|
||||
|
||||
- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。
|
||||
- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。
|
||||
- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py);
|
||||
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
|
||||
- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
|
||||
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
|
||||
|
||||
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。
|
||||
|
||||
@@ -70,22 +71,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`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +156,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 / 手动触发) |
|
||||
|
||||
---
|
||||
|
||||
@@ -224,6 +217,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
|
||||
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`
|
||||
- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件
|
||||
- [ ] `python tests/run_tests.py -v` 全部通过
|
||||
- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`)
|
||||
- [ ] 文本文件为 UTF-8 without BOM,无 `U+FEFF`(同上)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,8 @@ import re
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
from service.browser_session import browser_session, find_chrome_executable
|
||||
from util.constants import (
|
||||
@@ -45,8 +47,7 @@ class SimulatorBrowserRpaAdapter(BatchAdapterBase):
|
||||
) -> None:
|
||||
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
|
||||
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
|
||||
|
||||
243
release.ps1
243
release.ps1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -125,11 +125,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 +139,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 +162,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``;不得默认开启。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
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(
|
||||
@@ -165,19 +165,19 @@ class TestDocsStandards(unittest.TestCase):
|
||||
self.assertIn("RpaVideoSession", text)
|
||||
self.assertIn("1.0.14", text)
|
||||
|
||||
def test_adapter_md_covers_four_tiers_and_allow_flags(self) -> None:
|
||||
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")
|
||||
|
||||
165
tests/test_release_packaging_constraints.py
Normal file
165
tests/test_release_packaging_constraints.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from _support import get_skill_root
|
||||
|
||||
SCRIPTS_MAX_LINES = 1000
|
||||
SCRIPTS_RECOMMENDED_MAX_LINES = 900
|
||||
|
||||
TEXT_EXTENSIONS = frozenset(
|
||||
{
|
||||
".py",
|
||||
".md",
|
||||
".yml",
|
||||
".yaml",
|
||||
".json",
|
||||
".toml",
|
||||
".ini",
|
||||
".txt",
|
||||
".ps1",
|
||||
".sh",
|
||||
}
|
||||
)
|
||||
|
||||
SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"})
|
||||
|
||||
SKIP_DIR_NAMES = frozenset(
|
||||
{
|
||||
".git",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"rpa-artifacts",
|
||||
"videos",
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_EXTENSIONS = frozenset(
|
||||
{
|
||||
".db",
|
||||
".sqlite",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".mp4",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_text_candidate(path: str) -> bool:
|
||||
basename = os.path.basename(path)
|
||||
if basename in SPECIAL_TEXT_FILES:
|
||||
return True
|
||||
ext = os.path.splitext(basename)[1].lower()
|
||||
if ext in BINARY_EXTENSIONS:
|
||||
return False
|
||||
return ext in TEXT_EXTENSIONS
|
||||
|
||||
|
||||
def _iter_text_files(skill_root: str) -> list[str]:
|
||||
found: list[str] = []
|
||||
for root, dirnames, filenames in os.walk(skill_root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
for name in filenames:
|
||||
full = os.path.join(root, name)
|
||||
if _is_text_candidate(full):
|
||||
found.append(full)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _iter_scripts_py_files(skill_root: str) -> list[str]:
|
||||
scripts_dir = os.path.join(skill_root, "scripts")
|
||||
found: list[str] = []
|
||||
if not os.path.isdir(scripts_dir):
|
||||
return found
|
||||
for root, dirnames, filenames in os.walk(scripts_dir):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
for name in filenames:
|
||||
if name.endswith(".py"):
|
||||
found.append(os.path.join(root, name))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
class TestReleasePackagingConstraints(unittest.TestCase):
|
||||
def test_scripts_py_files_under_pyarmor_line_limit(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
offenders: list[str] = []
|
||||
for path in _iter_scripts_py_files(skill_root):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
line_count = sum(1 for _ in f)
|
||||
if line_count >= SCRIPTS_MAX_LINES:
|
||||
rel = os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
offenders.append(
|
||||
f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})"
|
||||
)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=(
|
||||
"PyArmor trial/free mode may fail when a single Python file reaches "
|
||||
f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. "
|
||||
f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. "
|
||||
f"Offenders: {offenders}"
|
||||
),
|
||||
)
|
||||
|
||||
def test_text_files_are_utf8_without_bom(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
bom_failures: list[str] = []
|
||||
decode_failures: list[str] = []
|
||||
feff_failures: list[str] = []
|
||||
|
||||
for path in _iter_text_files(skill_root):
|
||||
rel = os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
bom_failures.append(rel)
|
||||
continue
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
decode_failures.append(f"{rel}: {exc}")
|
||||
continue
|
||||
|
||||
if "\ufeff" in text:
|
||||
feff_failures.append(rel)
|
||||
|
||||
self.assertEqual(
|
||||
bom_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). "
|
||||
f"Offenders: {bom_failures}"
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
decode_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must be valid UTF-8 without BOM. "
|
||||
f"Offenders: {decode_failures}"
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
feff_failures,
|
||||
[],
|
||||
msg=(
|
||||
"Files must not contain U+FEFF; save as UTF-8 without BOM. "
|
||||
f"Offenders: {feff_failures}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user