# -*- 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)