test(compat): add v1 compatibility suite; docs: add V1_COMPATIBILITY.md
All checks were successful
技能自动化发布 / release (push) Successful in 9s
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行。
|
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行。
|
||||||
|
|
||||||
> **说明**:`scripts/cli/app.py` 内置的帮助字符串 `_USAGE` 主要覆盖 platform / account / credential / lease / open;实际路由还支持 **`account init-master-key` / `account export-credentials` / `account import-credentials`**(本文档以代码为准)。
|
> **说明**:内置 `_USAGE`(无参数启动 CLI)已列出加密三类子命令;更完整的 JSON 约定仍以本文与源码为准。
|
||||||
|
|
||||||
|
|
||||||
## stdout / JSON 约定(集成必读)
|
## stdout / JSON 约定(集成必读)
|
||||||
|
|
||||||
@@ -201,3 +202,7 @@ ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-maste
|
|||||||
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
||||||
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
||||||
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
||||||
|
|
||||||
|
## 内置 `_USAGE`(阶段 F)
|
||||||
|
|
||||||
|
无参数运行 CLI(例如 `python scripts/main.py` / `python -m cli.app`,cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。
|
||||||
|
|||||||
43
references/V1_COMPATIBILITY.md
Normal file
43
references/V1_COMPATIBILITY.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# v1 调用方兼容承诺(account-manager v2)
|
||||||
|
|
||||||
|
account-manager v2 **不破坏**下列面向脚本 / 其它仓库(通过 CLI subprocess 集成)的契约。
|
||||||
|
|
||||||
|
## CLI 命令
|
||||||
|
|
||||||
|
下列命令的**子命令名与既有必填参数**保持不变(v2 仅 **扩展** 可选参数,不移除):
|
||||||
|
|
||||||
|
- `account pick-web` / `lease release` / `account mark-used` / `account mark-session` / `account mark-error`
|
||||||
|
- `account get` / `account list` / `account add-web` / `account add-secret`
|
||||||
|
- 兼容入口:`get`、`list-json`、`pick-web`、`add`、`delete`、`open`
|
||||||
|
|
||||||
|
加密相关:`account init-master-key` / `account export-credentials` / `account import-credentials`(**仅新增**,不影响旧调用方)。
|
||||||
|
|
||||||
|
内置 `--help` / 无参数打印的 `_USAGE` 文案见仓库 `scripts/cli/app.py`(阶段 F 已与路由对齐)。
|
||||||
|
|
||||||
|
## JSON 字段
|
||||||
|
|
||||||
|
- **`account pick-web`**:成功时为 **单层 JSON 对象**(**无** `success`/`data` 外层)。保留字段包括但不限于:`id`、`platform_key`、`environment`、`role`、`profile_dir`、`lease_token`、`lease_expires_at`(带 `--lease` 时)。v2 **允许追加**字段(如 `auth_strategy`、`session_persistent`、`device_fingerprint`),**不得改名或删除**上述 v1 字段。
|
||||||
|
- **`account get`**:仍为单行账号 JSON(字段集合见 [SCHEMA.md](SCHEMA.md))。
|
||||||
|
- **`lease release` / `account mark-*`**:成功时使用 `_ok_json`(`{"success": true, "data": {...}}`);失败为统一错误 JSON。
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
下列 **`error.code` 字面值**保持不变:
|
||||||
|
|
||||||
|
- `ERROR:NO_ACCOUNT`
|
||||||
|
- `ERROR:LEASE_CONFLICT`
|
||||||
|
- `ERROR:ACCOUNT_NOT_FOUND`
|
||||||
|
- `ERROR:INVALID_PLATFORM`
|
||||||
|
- `ERROR:ACCOUNT_DISABLED`
|
||||||
|
- `ERROR:DB_ERROR`
|
||||||
|
|
||||||
|
v2 新增的 `ERR_*` 代码(如 `ERR_LEASE_INVALID`、`ERR_AUTH_STRATEGY_NOT_SUPPORTED`)仅出现在 **v2 新命令**路径,不应干扰上述 monitor 命令的失败解析。
|
||||||
|
|
||||||
|
## stderr
|
||||||
|
|
||||||
|
v2 可能在 stderr 打印迁移摘要、`[export] WARNING` 等。**调用方应只解析 stdout**,并与 monitor 一样优先取 **最后一行非空行** 做 `json.loads`(见集成测试 `tests/test_v1_compatibility.py`)。
|
||||||
|
|
||||||
|
## 集成验证
|
||||||
|
|
||||||
|
- 仓库内 **`tests/test_v1_compatibility.py`** 以 subprocess 覆盖典型调用序列与 stdout 形态。
|
||||||
|
- 已知调用方:**`monitor-competitor-rates`**(物流仓库)—— 仅 CLI,无 Python import。
|
||||||
33
tests/test_usage.py
Normal file
33
tests/test_usage.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Smoke test: built-in CLI usage lists routed account subcommands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_includes_all_account_subcommands():
|
||||||
|
env = {**os.environ, "PYTHONPATH": _scripts}
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "-m", "cli.app"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
cwd=_scripts,
|
||||||
|
)
|
||||||
|
assert r.returncode == 1
|
||||||
|
usage = r.stdout
|
||||||
|
for cmd in (
|
||||||
|
"init-master-key",
|
||||||
|
"export-credentials",
|
||||||
|
"import-credentials",
|
||||||
|
"add-web",
|
||||||
|
"get-credential",
|
||||||
|
"pick-web",
|
||||||
|
):
|
||||||
|
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
||||||
332
tests/test_v1_compatibility.py
Normal file
332
tests/test_v1_compatibility.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""v1 CLI compatibility — subprocess-only, mirrors monitor-competitor-rates calling patterns."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||||
|
if _scripts not in sys.path:
|
||||||
|
sys.path.insert(0, _scripts)
|
||||||
|
|
||||||
|
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_env(roots: dict) -> dict:
|
||||||
|
e = {**os.environ}
|
||||||
|
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
|
||||||
|
e["JIANGCHANG_USER_ID"] = roots["user_id"]
|
||||||
|
e["CLAW_DATA_ROOT"] = roots["data_root"]
|
||||||
|
e["CLAW_USER_ID"] = roots["user_id"]
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess:
|
||||||
|
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, MAIN_PY, *args],
|
||||||
|
input=inp,
|
||||||
|
capture_output=True,
|
||||||
|
env=env,
|
||||||
|
cwd=os.path.dirname(_scripts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_last_json(stdout: str) -> dict:
|
||||||
|
lines = [ln for ln in (stdout or "").strip().split("\n") if ln.strip()]
|
||||||
|
assert lines, "stdout had no non-empty lines"
|
||||||
|
return json.loads(lines[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli_json_last(env: dict, args: list[str], *, stdin: str | None = None) -> dict:
|
||||||
|
"""Parse **last** stdout line as JSON (monitor `_parse_stdout_json` semantics)."""
|
||||||
|
r = _run_cli(env, args, stdin=stdin)
|
||||||
|
assert r.returncode == 0, (r.stdout, r.stderr)
|
||||||
|
return _parse_last_json(r.stdout.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def iso_cli_env():
|
||||||
|
tmp = tempfile.mkdtemp(prefix="v1_compat_")
|
||||||
|
data_root = os.path.join(tmp, "claw-data")
|
||||||
|
os.makedirs(data_root)
|
||||||
|
uid = "compat_user"
|
||||||
|
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_monitor_account(env: dict) -> dict:
|
||||||
|
out = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"add-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--label",
|
||||||
|
"monitor-test",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--tenant-id",
|
||||||
|
"tenant-test",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert out.get("success") is True
|
||||||
|
return out["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1PickWeb:
|
||||||
|
def test_pick_web_returns_v1_shape(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
add_data = _add_monitor_account(env)
|
||||||
|
aid = add_data["id"]
|
||||||
|
assert isinstance(aid, int)
|
||||||
|
|
||||||
|
pick = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
"--purpose",
|
||||||
|
"maersk_sim_rpa",
|
||||||
|
"--ttl-sec",
|
||||||
|
"1800",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("id", "platform_key", "environment", "role", "profile_dir", "lease_token"):
|
||||||
|
assert key in pick, f"pick-web 缺少 v1 字段 {key}"
|
||||||
|
|
||||||
|
assert isinstance(pick["id"], int)
|
||||||
|
assert isinstance(pick["lease_token"], str) and len(pick["lease_token"]) > 0
|
||||||
|
assert pick["platform_key"] == "maersk"
|
||||||
|
assert pick["environment"] == "simulator"
|
||||||
|
assert pick["role"] == "booking"
|
||||||
|
|
||||||
|
for v2_key in ("auth_strategy", "session_persistent", "device_fingerprint"):
|
||||||
|
assert v2_key in pick, f"v2 字段 {v2_key} 应在 pick-web 返回中"
|
||||||
|
|
||||||
|
assert len(pick) >= 10
|
||||||
|
|
||||||
|
def test_pick_web_no_account_returns_legacy_error_code(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
proc = _run_cli(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0
|
||||||
|
payload = _parse_last_json(proc.stdout.decode("utf-8"))
|
||||||
|
assert payload.get("success") is False
|
||||||
|
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||||
|
|
||||||
|
def test_pick_web_lease_race_returns_legacy_error_shape(self, iso_cli_env):
|
||||||
|
"""Parallel picks share one account — second outcome may be NO_ACCOUNT or LEASE_CONFLICT."""
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
_add_monitor_account(env)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
"--ttl-sec",
|
||||||
|
"3600",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _pick():
|
||||||
|
return _run_cli(env, args)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||||
|
f1 = ex.submit(_pick)
|
||||||
|
f2 = ex.submit(_pick)
|
||||||
|
wait([f1, f2])
|
||||||
|
proc_a = f1.result()
|
||||||
|
proc_b = f2.result()
|
||||||
|
|
||||||
|
payloads = []
|
||||||
|
for proc in (proc_a, proc_b):
|
||||||
|
assert proc.returncode == 0
|
||||||
|
payloads.append(_parse_last_json(proc.stdout.decode("utf-8")))
|
||||||
|
|
||||||
|
codes = []
|
||||||
|
successes = 0
|
||||||
|
for p in payloads:
|
||||||
|
if p.get("success") is False:
|
||||||
|
codes.append(p["error"]["code"])
|
||||||
|
elif "lease_token" in p:
|
||||||
|
successes += 1
|
||||||
|
|
||||||
|
assert successes >= 1
|
||||||
|
assert "ERROR:LEASE_CONFLICT" in codes or "ERROR:NO_ACCOUNT" in codes
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1LeaseAndMarks:
|
||||||
|
def test_lease_release_returns_success_json(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
_add_monitor_account(env)
|
||||||
|
pick = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
"--ttl-sec",
|
||||||
|
"900",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
tok = pick["lease_token"]
|
||||||
|
rel = run_cli_json_last(env, ["lease", "release", tok])
|
||||||
|
assert rel.get("success") is True
|
||||||
|
assert rel["data"]["status"] == "released"
|
||||||
|
|
||||||
|
def test_mark_used_returns_success(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
aid = _add_monitor_account(env)["id"]
|
||||||
|
out = run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||||
|
assert out.get("success") is True
|
||||||
|
assert int(out["data"]["id"]) == aid
|
||||||
|
|
||||||
|
def test_mark_session_accepts_v1_status_values(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
aid = _add_monitor_account(env)["id"]
|
||||||
|
for status in ("likely_valid", "needs_login", "unknown"):
|
||||||
|
out = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
["account", "mark-session", str(aid), "--session-status", status],
|
||||||
|
)
|
||||||
|
assert out.get("success") is True
|
||||||
|
assert out["data"]["session_status"] == status
|
||||||
|
|
||||||
|
def test_mark_error_accepts_long_message(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
aid = _add_monitor_account(env)["id"]
|
||||||
|
long_msg = "x" * 600
|
||||||
|
out = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"mark-error",
|
||||||
|
str(aid),
|
||||||
|
"--code",
|
||||||
|
"ERR_TEST",
|
||||||
|
"--message",
|
||||||
|
long_msg,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert out.get("success") is True
|
||||||
|
assert out["data"]["error_code"] == "ERR_TEST"
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1EndToEnd:
|
||||||
|
def test_full_monitor_sequence_end_to_end(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
aid = _add_monitor_account(env)["id"]
|
||||||
|
|
||||||
|
pick1 = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
"--purpose",
|
||||||
|
"maersk_sim_rpa",
|
||||||
|
"--ttl-sec",
|
||||||
|
"900",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
tok = pick1["lease_token"]
|
||||||
|
assert pick1["id"] == aid
|
||||||
|
|
||||||
|
run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||||
|
run_cli_json_last(
|
||||||
|
env,
|
||||||
|
["account", "mark-session", str(aid), "--session-status", "likely_valid"],
|
||||||
|
)
|
||||||
|
run_cli_json_last(env, ["lease", "release", tok])
|
||||||
|
|
||||||
|
pick2 = run_cli_json_last(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--environment",
|
||||||
|
"simulator",
|
||||||
|
"--role",
|
||||||
|
"booking",
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
"monitor-competitor-rates",
|
||||||
|
"--ttl-sec",
|
||||||
|
"900",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "lease_token" in pick2
|
||||||
|
|
||||||
|
acc = run_cli_json_last(env, ["account", "get", str(aid)])
|
||||||
|
assert acc.get("session_status") == "likely_valid"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStdoutIsolation:
|
||||||
|
def test_stderr_does_not_pollute_stdout_last_line(self, iso_cli_env):
|
||||||
|
env = _cli_env(iso_cli_env)
|
||||||
|
proc = _run_cli(env, ["account", "export-credentials", "--plaintext"])
|
||||||
|
assert proc.returncode == 0
|
||||||
|
assert b"[export] WARNING" in proc.stderr
|
||||||
|
lines = [ln for ln in proc.stdout.decode("utf-8").strip().split("\n") if ln.strip()]
|
||||||
|
payload = json.loads(lines[-1])
|
||||||
|
assert isinstance(payload, list)
|
||||||
Reference in New Issue
Block a user