Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
@@ -0,0 +1,102 @@
"""
Smoke tests for the darwinian-evolver optional skill.
We can't actually run the evolution loop in CI (it needs network + a paid LLM),
so these tests verify:
- SKILL.md frontmatter conforms to the hardline format
- shipped scripts parse as valid Python
- the scripts reference the right env var / module paths
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
import pytest
import yaml
SKILL_DIR = Path(__file__).resolve().parents[2] / "optional-skills" / "research" / "darwinian-evolver"
@pytest.fixture(scope="module")
def frontmatter() -> dict:
src = (SKILL_DIR / "SKILL.md").read_text()
m = re.search(r"^---\n(.*?)\n---", src, re.DOTALL)
assert m, "SKILL.md missing YAML frontmatter"
return yaml.safe_load(m.group(1))
def test_skill_dir_exists() -> None:
assert SKILL_DIR.is_dir(), f"missing skill dir: {SKILL_DIR}"
def test_skill_md_present() -> None:
assert (SKILL_DIR / "SKILL.md").is_file()
def test_description_under_60_chars(frontmatter) -> None:
desc = frontmatter["description"]
assert len(desc) <= 60, f"description is {len(desc)} chars (hardline ≤60): {desc!r}"
def test_name_matches_dir(frontmatter) -> None:
assert frontmatter["name"] == "darwinian-evolver"
def test_platforms_excludes_windows(frontmatter) -> None:
# Upstream uses func_timeout (POSIX signals) and uv subprocess pipelines; the
# skill is gated [linux, macos]. If we ever port to Windows, update this test
# to assert ["linux", "macos", "windows"].
assert "windows" not in frontmatter["platforms"]
assert set(frontmatter["platforms"]) >= {"linux", "macos"}
def test_author_credits_contributor(frontmatter) -> None:
author = frontmatter["author"]
assert "Bihruze" in author, f"author should credit the original contributor: {author!r}"
def test_license_mit(frontmatter) -> None:
assert frontmatter["license"] == "MIT"
@pytest.mark.parametrize(
"path",
[
"scripts/parrot_openrouter.py",
"scripts/show_snapshot.py",
"templates/custom_problem_template.py",
],
)
def test_shipped_scripts_parse(path: str) -> None:
src = (SKILL_DIR / path).read_text()
ast.parse(src) # raises SyntaxError on broken Python
def test_parrot_script_uses_openrouter() -> None:
src = (SKILL_DIR / "scripts" / "parrot_openrouter.py").read_text()
assert "OPENROUTER_API_KEY" in src, "parrot driver should read OPENROUTER_API_KEY"
assert "openrouter.ai/api/v1" in src, "parrot driver should target OpenRouter"
assert "EVOLVER_MODEL" in src, "model should be overridable via EVOLVER_MODEL"
def test_parrot_script_has_error_swallowing() -> None:
"""Provider content-filter / rate-limit must not kill the run — see Pitfall 2."""
src = (SKILL_DIR / "scripts" / "parrot_openrouter.py").read_text()
assert "LLM_ERROR" in src, "_prompt_llm should swallow provider errors and tag them"
def test_skill_calls_out_agpl(frontmatter) -> None:
"""The upstream tool is AGPL-3.0. The skill MUST flag this so users don't
import it into MIT-licensed code by accident."""
src = (SKILL_DIR / "SKILL.md").read_text()
assert "AGPL" in src, "SKILL.md must mention upstream AGPL license"
def test_skill_pitfalls_section_present() -> None:
src = (SKILL_DIR / "SKILL.md").read_text()
assert "## Pitfalls" in src
# Pitfalls we discovered during the spike — keep them in sync with reality.
assert "Initial organism must be viable" in src
assert "generator" in src # loop.run() pitfall
+87
View File
@@ -0,0 +1,87 @@
"""Tests for skills/media/youtube-content/scripts/fetch_transcript.py (issue #22243)."""
import sys
from pathlib import Path
from unittest import mock
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "skills" / "media" / "youtube-content" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import fetch_transcript
class TestExtractVideoId:
def test_standard_watch_url(self):
assert fetch_transcript.extract_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ") == "dQw4w9WgXcQ"
def test_short_url(self):
assert fetch_transcript.extract_video_id("https://youtu.be/dQw4w9WgXcQ") == "dQw4w9WgXcQ"
def test_bare_video_id(self):
assert fetch_transcript.extract_video_id("dQw4w9WgXcQ") == "dQw4w9WgXcQ"
def test_shorts_url(self):
assert fetch_transcript.extract_video_id("https://www.youtube.com/shorts/dQw4w9WgXcQ") == "dQw4w9WgXcQ"
def test_embed_url(self):
assert fetch_transcript.extract_video_id("https://www.youtube.com/embed/dQw4w9WgXcQ") == "dQw4w9WgXcQ"
def test_with_extra_params(self):
assert fetch_transcript.extract_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42") == "dQw4w9WgXcQ"
class TestFormatTimestamp:
def test_seconds_only(self):
assert fetch_transcript.format_timestamp(90) == "1:30"
def test_with_hours(self):
assert fetch_transcript.format_timestamp(3661) == "1:01:01"
def test_zero(self):
assert fetch_transcript.format_timestamp(0) == "0:00"
def test_minutes_only(self):
assert fetch_transcript.format_timestamp(600) == "10:00"
class TestFetchTranscriptImportError:
def test_missing_dep_exits_with_message(self, capsys):
"""fetch_transcript exits with code 1 and prints install hint when package missing (issue #22243)."""
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "youtube_transcript_api":
raise ImportError("No module named 'youtube_transcript_api'")
return real_import(name, *args, **kwargs)
with mock.patch("builtins.__import__", side_effect=mock_import):
with pytest.raises(SystemExit) as exc_info:
fetch_transcript.fetch_transcript("dQw4w9WgXcQ")
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "youtube-transcript-api" in captured.err
class TestPyprojectDeclaresYoutubeExtra:
def test_youtube_extra_declared_in_pyproject(self):
"""youtube-transcript-api must be listed in pyproject.toml [youtube] extra (issue #22243)."""
import tomllib
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
extras = data.get("project", {}).get("optional-dependencies", {})
assert "youtube" in extras, "Missing [youtube] extra in pyproject.toml"
youtube_deps = " ".join(extras["youtube"])
assert "youtube-transcript-api" in youtube_deps
def test_youtube_extra_included_in_all(self):
"""[all] extra must include hermes-agent[youtube] (issue #22243)."""
import tomllib
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
all_deps = " ".join(data["project"]["optional-dependencies"].get("all", []))
assert "youtube" in all_deps, "[all] extra does not include hermes-agent[youtube]"
+447
View File
@@ -0,0 +1,447 @@
"""Regression tests for Google Workspace OAuth setup.
These tests cover the headless/manual auth-code flow where the browser step and
code exchange happen in separate process invocations.
"""
import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/setup.py"
)
class FakeCredentials:
def __init__(self, payload=None):
self._payload = payload or {
"token": "access-token",
"refresh_token": "refresh-token",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "client-id",
"client_secret": "client-secret",
"scopes": [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/documents.readonly",
],
}
def to_json(self):
return json.dumps(self._payload)
class FakeFlow:
created = []
default_state = "generated-state"
default_verifier = "generated-code-verifier"
credentials_payload = None
fetch_error = None
def __init__(
self,
client_secrets_file,
scopes,
*,
redirect_uri=None,
state=None,
code_verifier=None,
autogenerate_code_verifier=False,
):
self.client_secrets_file = client_secrets_file
self.scopes = scopes
self.redirect_uri = redirect_uri
self.state = state
self.code_verifier = code_verifier
self.autogenerate_code_verifier = autogenerate_code_verifier
self.authorization_kwargs = None
self.fetch_token_calls = []
self.credentials = FakeCredentials(self.credentials_payload)
if autogenerate_code_verifier and not self.code_verifier:
self.code_verifier = self.default_verifier
if not self.state:
self.state = self.default_state
@classmethod
def reset(cls):
cls.created = []
cls.default_state = "generated-state"
cls.default_verifier = "generated-code-verifier"
cls.credentials_payload = None
cls.fetch_error = None
@classmethod
def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs):
inst = cls(client_secrets_file, scopes, **kwargs)
cls.created.append(inst)
return inst
def authorization_url(self, **kwargs):
self.authorization_kwargs = kwargs
return f"https://auth.example/authorize?state={self.state}", self.state
def fetch_token(self, **kwargs):
self.fetch_token_calls.append(kwargs)
if self.fetch_error:
raise self.fetch_error
@pytest.fixture
def setup_module(monkeypatch, tmp_path):
FakeFlow.reset()
google_auth_module = types.ModuleType("google_auth_oauthlib")
flow_module = types.ModuleType("google_auth_oauthlib.flow")
flow_module.Flow = FakeFlow
google_auth_module.flow = flow_module
monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_auth_module)
monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", flow_module)
spec = importlib.util.spec_from_file_location("google_workspace_setup_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
monkeypatch.setattr(module, "_ensure_deps", lambda: None)
monkeypatch.setattr(module, "CLIENT_SECRET_PATH", tmp_path / "google_client_secret.json")
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
monkeypatch.setattr(module, "PENDING_AUTH_PATH", tmp_path / "google_oauth_pending.json", raising=False)
client_secret = {
"installed": {
"client_id": "client-id",
"client_secret": "client-secret",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret))
return module
class TestGetAuthUrl:
def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys):
setup_module.get_auth_url()
out = capsys.readouterr().out.strip()
assert out == "https://auth.example/authorize?state=generated-state"
saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text())
assert saved["state"] == "generated-state"
assert saved["code_verifier"] == "generated-code-verifier"
flow = FakeFlow.created[-1]
assert flow.autogenerate_code_verifier is True
assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"}
class TestExchangeAuthCode:
def test_reuses_saved_pkce_material_for_plain_code(self, setup_module):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.exchange_auth_code("4/test-auth-code")
flow = FakeFlow.created[-1]
assert flow.state == "saved-state"
assert flow.code_verifier == "saved-verifier"
assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}]
saved = json.loads(setup_module.TOKEN_PATH.read_text())
assert saved["token"] == "access-token"
assert saved["type"] == "authorized_user"
assert not setup_module.PENDING_AUTH_PATH.exists()
def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.exchange_auth_code(
"http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail"
)
flow = FakeFlow.created[-1]
assert flow.fetch_token_calls == [{"code": "4/extracted-code"}]
def test_passes_scopes_from_redirect_url_to_flow(self, setup_module):
"""Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES)."""
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
g1 = "https://www.googleapis.com/auth/gmail.readonly"
g2 = "https://www.googleapis.com/auth/calendar"
from urllib.parse import quote
scope_q = quote(f"{g1} {g2}", safe="")
setup_module.exchange_auth_code(
f"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}"
)
flow = FakeFlow.created[-1]
assert flow.scopes == [g1, g2]
def test_rejects_state_mismatch(self, setup_module, capsys):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
with pytest.raises(SystemExit):
setup_module.exchange_auth_code(
"http://localhost:1/?code=4/extracted-code&state=wrong-state"
)
out = capsys.readouterr().out
assert "state mismatch" in out.lower()
assert not setup_module.TOKEN_PATH.exists()
def test_requires_pending_auth_session(self, setup_module, capsys):
with pytest.raises(SystemExit):
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "run --auth-url first" in out.lower()
assert not setup_module.TOKEN_PATH.exists()
def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys):
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
FakeFlow.fetch_error = Exception("invalid_grant: Missing code verifier")
with pytest.raises(SystemExit):
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "token exchange failed" in out.lower()
assert setup_module.PENDING_AUTH_PATH.exists()
assert not setup_module.TOKEN_PATH.exists()
def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):
"""Partial scopes are accepted with a warning (gws migration: v2.0)."""
setup_module.PENDING_AUTH_PATH.write_text(
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
)
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES}))
FakeFlow.credentials_payload = {
"token": "***",
"refresh_token": "***",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "client-id",
"client_secret": "client-secret",
"scopes": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/spreadsheets",
],
}
setup_module.exchange_auth_code("4/test-auth-code")
out = capsys.readouterr().out
assert "warning" in out.lower()
assert "missing" in out.lower()
# Token is saved (partial scopes accepted)
assert setup_module.TOKEN_PATH.exists()
# Pending auth is cleaned up
assert not setup_module.PENDING_AUTH_PATH.exists()
class TestHermesConstantsFallback:
"""Tests for _hermes_home.py fallback when hermes_constants is unavailable."""
HELPER_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/_hermes_home.py"
)
def _load_helper(self, monkeypatch):
"""Load _hermes_home.py with hermes_constants blocked."""
monkeypatch.setitem(sys.modules, "hermes_constants", None)
spec = importlib.util.spec_from_file_location("_hermes_home_test", self.HELPER_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path):
"""When hermes_constants is missing, HERMES_HOME comes from env var."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == tmp_path / "custom-hermes"
def test_fallback_defaults_to_dot_hermes(self, monkeypatch):
"""When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes."""
monkeypatch.delenv("HERMES_HOME", raising=False)
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == Path.home() / ".hermes"
def test_fallback_ignores_empty_hermes_home(self, monkeypatch):
"""Empty/whitespace HERMES_HOME is treated as unset."""
monkeypatch.setenv("HERMES_HOME", " ")
module = self._load_helper(monkeypatch)
assert module.get_hermes_home() == Path.home() / ".hermes"
def test_fallback_display_hermes_home_shortens_path(self, monkeypatch):
"""Fallback display_hermes_home() uses ~/ shorthand like the real one."""
monkeypatch.delenv("HERMES_HOME", raising=False)
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "~/.hermes"
def test_fallback_display_hermes_home_profile_path(self, monkeypatch):
"""Fallback display_hermes_home() handles profile paths under ~/."""
monkeypatch.setenv("HERMES_HOME", str(Path.home() / ".hermes/profiles/coder"))
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "~/.hermes/profiles/coder"
def test_fallback_display_hermes_home_custom_path(self, monkeypatch):
"""Fallback display_hermes_home() returns full path for non-home locations."""
monkeypatch.setenv("HERMES_HOME", "/opt/hermes-custom")
module = self._load_helper(monkeypatch)
assert module.display_hermes_home() == "/opt/hermes-custom"
def test_delegates_to_hermes_constants_when_available(self):
"""When hermes_constants IS importable, _hermes_home delegates to it."""
spec = importlib.util.spec_from_file_location(
"_hermes_home_happy", self.HELPER_PATH
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
import hermes_constants
assert module.get_hermes_home is hermes_constants.get_hermes_home
assert module.display_hermes_home is hermes_constants.display_hermes_home
def _load_setup_module(monkeypatch):
"""Load setup.py without stubbing _ensure_deps (for install_deps tests)."""
spec = importlib.util.spec_from_file_location(
"google_workspace_setup_installdeps_test", SCRIPT_PATH
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _force_deps_missing(monkeypatch):
"""Make `import googleapiclient` / `import google_auth_oauthlib` fail so
install_deps() proceeds past its early-return short-circuit."""
for name in ("googleapiclient", "google_auth_oauthlib"):
monkeypatch.setitem(sys.modules, name, None)
class TestInstallDeps:
"""Tests for install_deps() interpreter/installer selection.
Regression coverage for the Hermes Docker image, whose venv is built with
`uv sync` and ships without pip — `sys.executable -m pip install` fails
with `No module named pip`, so install_deps() must fall back to uv.
"""
def test_returns_early_when_already_installed(self, monkeypatch):
"""If both libs import, no installer subprocess runs at all."""
module = _load_setup_module(monkeypatch)
# Don't force-missing: real test env has the libs importable. Guard
# against any subprocess being spawned.
calls = []
monkeypatch.setattr(
module.subprocess, "check_call", lambda *a, **k: calls.append(a)
)
# google_auth_oauthlib may not be installed in the test env; only run
# this assertion when the early-return path is actually reachable.
try:
import googleapiclient # noqa: F401
import google_auth_oauthlib # noqa: F401
except ImportError:
pytest.skip("Google libs not installed in test env")
assert module.install_deps() is True
assert calls == []
def test_uses_pip_when_available(self, monkeypatch):
"""When pip works, install_deps succeeds via pip and never calls uv."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
recorded = []
def fake_check_call(cmd, **kwargs):
recorded.append(cmd)
# pip path is the first attempt — succeed.
return 0
which_calls = []
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(
module.shutil, "which", lambda name: which_calls.append(name)
)
assert module.install_deps() is True
assert recorded[0][:3] == [module.sys.executable, "-m", "pip"]
# Control: uv must NOT be consulted when pip succeeds.
assert which_calls == []
def test_falls_back_to_uv_when_pip_missing(self, monkeypatch):
"""No pip → uv pip install --python <interpreter> is used."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
recorded = []
def fake_check_call(cmd, **kwargs):
recorded.append(cmd)
if cmd[:3] == [module.sys.executable, "-m", "pip"]:
raise module.subprocess.CalledProcessError(1, cmd)
return 0 # uv invocation succeeds
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
assert module.install_deps() is True
assert len(recorded) == 2
uv_cmd = recorded[1]
assert uv_cmd[0] == "/usr/local/bin/uv"
assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable]
for pkg in module.REQUIRED_PACKAGES:
assert pkg in uv_cmd
def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys):
"""No pip AND no uv → failure, with the [google] extra hint printed."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
def fake_check_call(cmd, **kwargs):
raise module.subprocess.CalledProcessError(1, cmd)
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: None)
assert module.install_deps() is False
out = capsys.readouterr().out
assert "hermes-agent[google]" in out
def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys):
"""uv present but its install fails → failure surfaced (not swallowed)."""
module = _load_setup_module(monkeypatch)
_force_deps_missing(monkeypatch)
def fake_check_call(cmd, **kwargs):
raise module.subprocess.CalledProcessError(1, cmd)
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
assert module.install_deps() is False
out = capsys.readouterr().out
assert "via uv" in out
+486
View File
@@ -0,0 +1,486 @@
"""Tests for Google Workspace gws bridge and CLI wrapper."""
import importlib.util
import json
import subprocess
import sys
import types
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
BRIDGE_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/gws_bridge.py"
)
API_PATH = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/scripts/google_api.py"
)
@pytest.fixture
def bridge_module(monkeypatch, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
spec = importlib.util.spec_from_file_location("gws_bridge_test", BRIDGE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
@pytest.fixture
def api_module(monkeypatch, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
spec = importlib.util.spec_from_file_location("gws_api_test", API_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
# Ensure the gws CLI code path is taken even when the binary isn't
# installed (CI). Without this, calendar_list() falls through to the
# Python SDK path which imports ``googleapiclient`` — not in deps.
module._gws_binary = lambda: "/usr/bin/gws"
# Bypass authentication check — no real token file in CI.
module._ensure_authenticated = lambda: None
return module
def _write_token(path: Path, *, token="ya29.test", expiry=None, **extra):
data = {
"token": token,
"refresh_token": "1//refresh",
"client_id": "123.apps.googleusercontent.com",
"client_secret": "secret",
"token_uri": "https://oauth2.googleapis.com/token",
**extra,
}
if expiry is not None:
data["expiry"] = expiry
path.write_text(json.dumps(data))
def test_bridge_returns_valid_token(bridge_module, tmp_path):
"""Non-expired token is returned without refresh."""
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
token_path = bridge_module.get_token_path()
_write_token(token_path, token="ya29.valid", expiry=future)
result = bridge_module.get_valid_token()
assert result == "ya29.valid"
def test_bridge_refreshes_expired_token(bridge_module, tmp_path):
"""Expired token triggers a refresh via token_uri."""
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
token_path = bridge_module.get_token_path()
_write_token(token_path, token="ya29.old", expiry=past)
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({
"access_token": "ya29.refreshed",
"expires_in": 3600,
}).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
result = bridge_module.get_valid_token()
assert result == "ya29.refreshed"
# Verify persisted
saved = json.loads(token_path.read_text())
assert saved["token"] == "ya29.refreshed"
assert saved["type"] == "authorized_user"
def test_bridge_refresh_passes_timeout_to_urlopen(bridge_module):
"""Token refresh must pass an explicit timeout so a hung Google endpoint
cannot block the agent turn indefinitely (no `timeout=` defaults to the
global socket timeout, which is unset)."""
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
token_path = bridge_module.get_token_path()
_write_token(token_path, token="ya29.old", expiry=past)
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({
"access_token": "ya29.refreshed",
"expires_in": 3600,
}).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp) as mocked:
bridge_module.get_valid_token()
assert mocked.call_count == 1
_, kwargs = mocked.call_args
assert kwargs.get("timeout") is not None, (
"urlopen call must pass timeout= to avoid hanging on unreachable upstream"
)
def test_bridge_refresh_exits_cleanly_on_network_error(bridge_module):
"""URLError/timeout during refresh exits 1 with a readable message
instead of crashing with a raw traceback."""
import urllib.error
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
token_path = bridge_module.get_token_path()
_write_token(token_path, token="ya29.old", expiry=past)
with patch(
"urllib.request.urlopen",
side_effect=urllib.error.URLError("timed out"),
):
with pytest.raises(SystemExit) as exc_info:
bridge_module.get_valid_token()
assert exc_info.value.code == 1
def test_bridge_exits_on_missing_token(bridge_module):
"""Missing token file causes exit with code 1."""
with pytest.raises(SystemExit):
bridge_module.get_valid_token()
def test_bridge_main_injects_token_env(bridge_module, tmp_path):
"""main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env."""
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
token_path = bridge_module.get_token_path()
_write_token(token_path, token="ya29.injected", expiry=future)
captured = {}
def capture_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
return MagicMock(returncode=0)
with patch.object(sys, "argv", ["gws_bridge.py", "gmail", "+triage"]):
with patch.object(subprocess, "run", side_effect=capture_run):
with pytest.raises(SystemExit):
bridge_module.main()
assert captured["env"]["GOOGLE_WORKSPACE_CLI_TOKEN"] == "ya29.injected"
assert captured["cmd"] == ["gws", "gmail", "+triage"]
def test_api_calendar_list_uses_events_list(api_module):
"""calendar_list calls _run_gws with events list + params."""
captured = {}
def capture_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stdout="{}", stderr="")
args = api_module.argparse.Namespace(
start="", end="", max=25, calendar="primary", func=api_module.calendar_list,
)
with patch.object(api_module.subprocess, "run", side_effect=capture_run):
api_module.calendar_list(args)
cmd = captured["cmd"]
# _gws_binary() returns "/usr/bin/gws", so cmd[0] is that binary
assert cmd[0] == "/usr/bin/gws"
assert "calendar" in cmd
assert "events" in cmd
assert "list" in cmd
assert "--params" in cmd
params = json.loads(cmd[cmd.index("--params") + 1])
assert "timeMin" in params
assert "timeMax" in params
assert params["calendarId"] == "primary"
def test_api_calendar_list_respects_date_range(api_module):
"""calendar list with --start/--end passes correct time bounds."""
captured = {}
def capture_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stdout="{}", stderr="")
args = api_module.argparse.Namespace(
start="2026-04-01T00:00:00Z",
end="2026-04-07T23:59:59Z",
max=25,
calendar="primary",
func=api_module.calendar_list,
)
with patch.object(api_module.subprocess, "run", side_effect=capture_run):
api_module.calendar_list(args)
cmd = captured["cmd"]
params_idx = cmd.index("--params")
params = json.loads(cmd[params_idx + 1])
assert params["timeMin"] == "2026-04-01T00:00:00Z"
assert params["timeMax"] == "2026-04-07T23:59:59Z"
@pytest.mark.parametrize(
"header_names",
[
("from", "to", "subject", "date"),
("From", "To", "Subject", "Date"),
],
)
def test_api_gmail_get_reads_headers_case_insensitively(api_module, capsys, header_names):
from_name, to_name, subject_name, date_name = header_names
def fake_run_gws(parts, *, params=None, body=None):
assert parts == ["gmail", "users", "messages", "get"]
assert params == {"userId": "me", "id": "msg-1", "format": "full"}
return {
"id": "msg-1",
"threadId": "thread-1",
"labelIds": ["INBOX"],
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": to_name, "value": "recipient@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"},
],
"body": {},
},
}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(message_id="msg-1", func=api_module.gmail_get)
api_module.gmail_get(args)
result = json.loads(capsys.readouterr().out)
assert result["from"] == "sender@example.com"
assert result["to"] == "recipient@example.com"
assert result["subject"] == "case bug"
assert result["date"] == "Fri, 29 May 2026 12:00:00 +0000"
@pytest.mark.parametrize(
"header_names",
[
("from", "to", "subject", "date"),
("From", "To", "Subject", "Date"),
],
)
def test_api_gmail_search_reads_headers_case_insensitively(
api_module,
capsys,
header_names,
):
from_name, to_name, subject_name, date_name = header_names
calls = []
def fake_run_gws(parts, *, params=None, body=None):
calls.append({"parts": parts, "params": params, "body": body})
if parts == ["gmail", "users", "messages", "list"]:
assert params == {"userId": "me", "q": "from:sender", "maxResults": 5}
return {"messages": [{"id": "msg-1"}]}
assert parts == ["gmail", "users", "messages", "get"]
assert params == {
"userId": "me",
"id": "msg-1",
"format": "metadata",
"metadataHeaders": ["From", "To", "Subject", "Date"],
}
return {
"id": "msg-1",
"threadId": "thread-1",
"labelIds": ["INBOX"],
"snippet": "preview",
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": to_name, "value": "recipient@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": date_name, "value": "Fri, 29 May 2026 12:00:00 +0000"},
],
},
}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
query="from:sender",
max=5,
func=api_module.gmail_search,
)
api_module.gmail_search(args)
assert len(calls) == 2
result = json.loads(capsys.readouterr().out)
assert result == [
{
"id": "msg-1",
"threadId": "thread-1",
"from": "sender@example.com",
"to": "recipient@example.com",
"subject": "case bug",
"date": "Fri, 29 May 2026 12:00:00 +0000",
"snippet": "preview",
"labels": ["INBOX"],
}
]
def test_api_gmail_send_uses_conventional_mime_header_casing(api_module):
captured = {}
def fake_run_gws(parts, *, params=None, body=None):
captured["parts"] = parts
captured["params"] = params
captured["body"] = body
return {"id": "sent-1", "threadId": "thread-1"}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
to="recipient@example.com",
subject="hello",
body="body",
html=False,
cc="copy@example.com",
from_header="sender@example.com",
thread_id="thread-1",
func=api_module.gmail_send,
)
api_module.gmail_send(args)
raw = api_module.base64.urlsafe_b64decode(captured["body"]["raw"])
raw_text = raw.decode()
assert "To: recipient@example.com" in raw_text
assert "Subject: hello" in raw_text
assert "Cc: copy@example.com" in raw_text
assert "From: sender@example.com" in raw_text
assert "\nto: " not in raw_text
assert "\nsubject: " not in raw_text
@pytest.mark.parametrize(
"header_names",
[
("from", "subject", "message-id"),
("From", "Subject", "Message-ID"),
],
)
def test_api_gmail_reply_reads_headers_case_insensitively_and_uses_conventional_mime_header_casing(
api_module,
header_names,
):
from_name, subject_name, message_id_name = header_names
calls = []
def fake_run_gws(parts, *, params=None, body=None):
calls.append({"parts": parts, "params": params, "body": body})
if parts == ["gmail", "users", "messages", "get"]:
assert params == {
"userId": "me",
"id": "msg-1",
"format": "metadata",
"metadataHeaders": ["From", "Subject", "Message-ID"],
}
return {
"id": "msg-1",
"threadId": "thread-1",
"payload": {
"headers": [
{"name": from_name, "value": "sender@example.com"},
{"name": subject_name, "value": "case bug"},
{"name": message_id_name, "value": "<msg-1@example.com>"},
],
},
}
assert parts == ["gmail", "users", "messages", "send"]
assert params == {"userId": "me"}
return {"id": "sent-1", "threadId": "thread-1"}
api_module._run_gws = fake_run_gws
args = api_module.argparse.Namespace(
message_id="msg-1",
body="reply body",
from_header="recipient@example.com",
func=api_module.gmail_reply,
)
api_module.gmail_reply(args)
assert len(calls) == 2
body = calls[1]["body"]
assert body["threadId"] == "thread-1"
raw = api_module.base64.urlsafe_b64decode(body["raw"])
raw_text = raw.decode()
assert "To: sender@example.com" in raw_text
assert "Subject: Re: case bug" in raw_text
assert "From: recipient@example.com" in raw_text
assert "In-Reply-To: <msg-1@example.com>" in raw_text
assert "References: <msg-1@example.com>" in raw_text
assert "\nto: " not in raw_text
assert "\nsubject: " not in raw_text
assert "\nin-reply-to: " not in raw_text
assert "\nreferences: " not in raw_text
def test_api_get_credentials_refresh_persists_authorized_user_type(api_module, monkeypatch):
token_path = api_module.TOKEN_PATH
_write_token(token_path, token="ya29.old")
class FakeCredentials:
def __init__(self):
self.expired = True
self.refresh_token = "1//refresh"
self.valid = True
def refresh(self, request):
self.expired = False
def to_json(self):
return json.dumps({
"token": "ya29.refreshed",
"refresh_token": "1//refresh",
"client_id": "123.apps.googleusercontent.com",
"client_secret": "secret",
"token_uri": "https://oauth2.googleapis.com/token",
})
class FakeCredentialsModule:
@staticmethod
def from_authorized_user_file(filename, scopes):
assert filename == str(token_path)
assert scopes == api_module.SCOPES
return FakeCredentials()
google_module = types.ModuleType("google")
oauth2_module = types.ModuleType("google.oauth2")
credentials_module = types.ModuleType("google.oauth2.credentials")
credentials_module.Credentials = FakeCredentialsModule
transport_module = types.ModuleType("google.auth.transport")
requests_module = types.ModuleType("google.auth.transport.requests")
requests_module.Request = lambda: object()
monkeypatch.setitem(sys.modules, "google", google_module)
monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module)
monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module)
monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module)
monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module)
creds = api_module.get_credentials()
saved = json.loads(token_path.read_text())
assert isinstance(creds, FakeCredentials)
assert saved["token"] == "ya29.refreshed"
assert saved["type"] == "authorized_user"
@@ -0,0 +1,101 @@
"""Regression test: google-workspace SKILL.md must declare required_credential_files.
PR #9931 accidentally removed the required_credential_files header, which broke
credential file mounting in Docker/Modal remote backends (#16452). This test
prevents the regression from silently reappearing.
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
SKILL_MD = (
Path(__file__).resolve().parents[2]
/ "skills/productivity/google-workspace/SKILL.md"
)
_EXPECTED_PATHS = {"google_token.json", "google_client_secret.json"}
def _parse_frontmatter(content: str) -> dict:
from agent.skill_utils import parse_frontmatter
fm, _ = parse_frontmatter(content)
return fm
class TestGoogleWorkspaceCredentialFiles:
def test_required_credential_files_present_in_skill_md(self):
content = SKILL_MD.read_text(encoding="utf-8")
fm = _parse_frontmatter(content)
entries = fm.get("required_credential_files")
assert entries, "required_credential_files missing from google-workspace SKILL.md"
assert isinstance(entries, list), "required_credential_files must be a list"
paths = {
(e["path"] if isinstance(e, dict) else e)
for e in entries
}
assert _EXPECTED_PATHS <= paths, (
f"Missing entries in required_credential_files: {_EXPECTED_PATHS - paths}"
)
def test_entries_are_registered_when_files_exist(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "google_token.json").write_text("{}")
(hermes_home / "google_client_secret.json").write_text("{}")
from tools.credential_files import (
clear_credential_files,
get_credential_file_mounts,
register_credential_files,
)
clear_credential_files()
try:
content = SKILL_MD.read_text(encoding="utf-8")
fm = _parse_frontmatter(content)
entries = fm.get("required_credential_files", [])
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files(entries)
assert missing == [], f"Unexpected missing files: {missing}"
mounts = get_credential_file_mounts()
container_paths = {m["container_path"] for m in mounts}
assert "/root/.hermes/google_token.json" in container_paths
assert "/root/.hermes/google_client_secret.json" in container_paths
finally:
clear_credential_files()
def test_missing_token_is_reported(self, tmp_path):
"""google_token.json absent (first-time setup) — reported as missing, client secret still mounts."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "google_client_secret.json").write_text("{}")
from tools.credential_files import (
clear_credential_files,
get_credential_file_mounts,
register_credential_files,
)
clear_credential_files()
try:
content = SKILL_MD.read_text(encoding="utf-8")
fm = _parse_frontmatter(content)
entries = fm.get("required_credential_files", [])
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files(entries)
assert "google_token.json" in missing
mounts = get_credential_file_mounts()
container_paths = {m["container_path"] for m in mounts}
assert "/root/.hermes/google_client_secret.json" in container_paths
assert "/root/.hermes/google_token.json" not in container_paths
finally:
clear_credential_files()
+358
View File
@@ -0,0 +1,358 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from unittest.mock import patch
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "optional-skills"
/ "blockchain"
/ "hyperliquid"
/ "scripts"
/ "hyperliquid_client.py"
)
def load_module():
spec = importlib.util.spec_from_file_location("hyperliquid_skill", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_normalize_perp_markets_extracts_change_and_volume():
mod = load_module()
payload = [
{
"universe": [
{"name": "BTC", "szDecimals": 5, "maxLeverage": 50},
{"name": "ETH", "szDecimals": 4, "maxLeverage": 25, "isDelisted": True},
]
},
[
{
"markPx": "100000",
"prevDayPx": "95000",
"funding": "0.0001",
"openInterest": "123456789",
"dayNtlVlm": "999999999",
},
{
"markPx": "2500",
"prevDayPx": "2600",
"funding": "-0.0002",
"openInterest": "20000000",
"dayNtlVlm": "11111111",
},
],
]
rows = mod._normalize_perp_markets(payload)
assert len(rows) == 2
assert rows[0]["coin"] == "BTC"
assert round(rows[0]["change_pct"], 2) == 5.26
assert rows[0]["day_ntl_vlm"] == "999999999"
assert rows[1]["is_delisted"] is True
def test_normalize_dexs_includes_first_perp_dex_placeholder():
mod = load_module()
rows = mod._normalize_dexs(
[
None,
{
"name": "test",
"fullName": "test dex",
"deployer": "0x1234567890abcdef1234567890abcdef12345678",
"assetToStreamingOiCap": [["COIN", "100"]],
},
]
)
assert rows[0]["label"] == "first-perp-dex"
assert rows[1]["label"] == "test"
assert rows[1]["asset_caps"] == 1
def test_main_markets_json_prints_normalized_payload(capsys):
mod = load_module()
payload = [
{"universe": [{"name": "BTC", "szDecimals": 5, "maxLeverage": 50}]},
[{"markPx": "101000", "prevDayPx": "100000", "dayNtlVlm": "10"}],
]
with patch.object(mod, "_post_info", return_value=payload):
exit_code = mod.main(["markets", "--limit", "1", "--json"])
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
assert exit_code == 0
assert rendered["count"] == 1
assert rendered["markets"][0]["coin"] == "BTC"
assert round(rendered["markets"][0]["change_pct"], 2) == 1.0
def test_main_candles_json_limits_rows(capsys):
mod = load_module()
payload = [
{"t": 1000, "o": "1", "h": "2", "l": "0.5", "c": "1.5", "v": "10", "n": 3},
{"t": 2000, "o": "1.5", "h": "2.5", "l": "1.4", "c": "2.0", "v": "20", "n": 5},
{"t": 3000, "o": "2.0", "h": "2.2", "l": "1.8", "c": "2.1", "v": "15", "n": 4},
]
with patch.object(mod, "_post_info", return_value=payload):
exit_code = mod.main(["candles", "BTC", "--limit", "2", "--json"])
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
assert exit_code == 0
assert rendered["count"] == 3
assert len(rendered["candles"]) == 2
assert rendered["summary"]["open"] == "1"
assert rendered["summary"]["close"] == "2.1"
def test_main_review_json_builds_market_context_and_findings(capsys):
mod = load_module()
def fake_post_info(payload):
payload_type = payload["type"]
if payload_type == "userFillsByTime":
return [
{"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}},
{"fill": {"coin": "BTC", "dir": "Open Long", "px": "100000", "sz": "0.1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 3000}},
{"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}},
{"fill": {"coin": "ETH", "dir": "Open Short", "px": "2000", "sz": "1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 1000}},
]
if payload_type == "candleSnapshot" and payload["req"]["coin"] == "BTC":
return [
{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3},
]
if payload_type == "candleSnapshot" and payload["req"]["coin"] == "ETH":
return [
{"t": 1000, "o": "2000", "h": "2210", "l": "1990", "c": "2200", "v": "50", "n": 10},
]
if payload_type == "fundingHistory" and payload["coin"] == "BTC":
return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}]
if payload_type == "fundingHistory" and payload["coin"] == "ETH":
return [{"coin": "ETH", "fundingRate": "0.0002", "premium": "0.0003", "time": 1000}]
raise AssertionError(f"Unexpected payload: {payload}")
with patch.object(mod, "_post_info", side_effect=fake_post_info):
exit_code = mod.main(["review", "0xabc", "--hours", "72", "--json"])
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
assert exit_code == 0
assert rendered["summary"]["fill_count"] == 4
assert rendered["summary"]["realized_pnl"] == 40.0
assert rendered["summary"]["total_fees"] == 11.0
assert rendered["summary"]["net_after_fees"] == 29.0
assert len(rendered["coin_reviews"]) == 2
eth_review = next(item for item in rendered["coin_reviews"] if item["coin"] == "ETH")
assert round(eth_review["market_context"]["price_change_pct"], 2) == 10.0
assert eth_review["market_context"]["average_funding_rate"] == 0.0002
assert any("ETH" in finding and "rising market" in finding for finding in rendered["findings"])
def test_main_review_json_respects_coin_filter(capsys):
mod = load_module()
def fake_post_info(payload):
if payload["type"] == "userFillsByTime":
return [
{"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}},
{"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}},
]
if payload["type"] == "candleSnapshot":
return [{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}]
if payload["type"] == "fundingHistory":
return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}]
raise AssertionError(f"Unexpected payload: {payload}")
with patch.object(mod, "_post_info", side_effect=fake_post_info):
exit_code = mod.main(["review", "0xabc", "--coin", "BTC", "--json"])
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
assert exit_code == 0
assert rendered["summary"]["fill_count"] == 1
assert rendered["summary"]["unique_coins"] == 1
assert rendered["coin_reviews"][0]["coin"] == "BTC"
def test_resolve_user_uses_env_fallback(monkeypatch):
mod = load_module()
monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv123")
assert mod._resolve_user("") == "0xenv123"
assert mod._resolve_user(None) == "0xenv123"
assert mod._resolve_user("0xcli456") == "0xcli456"
def test_resolve_user_errors_when_missing(monkeypatch, tmp_path):
mod = load_module()
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
try:
mod._resolve_user("")
except SystemExit as exc:
message = str(exc)
else:
raise AssertionError("Expected SystemExit when no user is provided")
assert "HYPERLIQUID_USER_ADDRESS" in message
def test_main_state_json_uses_env_fallback(monkeypatch, capsys):
mod = load_module()
monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv999")
with patch.object(
mod,
"_post_info",
return_value={"marginSummary": {"accountValue": "123"}, "assetPositions": [], "withdrawable": "50"},
) as mock_post:
exit_code = mod.main(["state", "--json"])
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
assert exit_code == 0
assert rendered["user"] == "0xenv999"
assert mock_post.call_args[0][0]["user"] == "0xenv999"
def test_env_lookup_reads_hermes_dotenv(tmp_path, monkeypatch):
mod = load_module()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(parents=True)
(hermes_home / ".env").write_text(
"HYPERLIQUID_USER_ADDRESS=0xdotenv123\nHYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
monkeypatch.delenv("HYPERLIQUID_API_URL", raising=False)
assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xdotenv123"
assert mod._resolve_user("") == "0xdotenv123"
assert mod._info_url() == "https://api.hyperliquid-testnet.xyz/info"
def test_user_dotenv_overrides_project_dotenv(tmp_path, monkeypatch):
mod = load_module()
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xproject\n", encoding="utf-8")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xuserhome\n", encoding="utf-8")
monkeypatch.chdir(project_dir)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False)
assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xuserhome"
def test_main_export_json_writes_expected_contract(tmp_path, capsys):
mod = load_module()
output_path = tmp_path / "exports" / "btc-1h.json"
def fake_post_info(payload):
if payload["type"] == "candleSnapshot":
return [
{"t": 1000, "o": "100", "h": "110", "l": "95", "c": "108", "v": "50", "n": 4},
{"t": 2000, "o": "108", "h": "115", "l": "107", "c": "112", "v": "60", "n": 5},
]
if payload["type"] == "fundingHistory":
return [
{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1500},
{"coin": "BTC", "fundingRate": "0.0003", "premium": "0.0004", "time": 2000},
]
raise AssertionError(f"Unexpected payload: {payload}")
with patch.object(mod, "_post_info", side_effect=fake_post_info):
exit_code = mod.main(
[
"export",
"BTC",
"--interval",
"1h",
"--hours",
"24",
"--end-time-ms",
"5000",
"--output",
str(output_path),
"--json",
]
)
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
saved = json.loads(output_path.read_text(encoding="utf-8"))
assert exit_code == 0
assert rendered["output_path"] == str(output_path)
assert saved["schema_version"] == "hyperliquid-market-export-v1"
assert saved["source"]["coin"] == "BTC"
assert saved["window"]["start_time_ms"] == 5000 - 24 * 60 * 60 * 1000
assert saved["window"]["end_time_ms"] == 5000
assert saved["summary"]["candle_count"] == 2
assert saved["summary"]["funding_count"] == 2
assert round(saved["summary"]["price_change_pct"], 2) == 12.0
assert saved["summary"]["average_funding_rate"] == 0.0002
assert len(saved["candles"]) == 2
assert len(saved["funding_history"]) == 2
def test_main_export_json_skips_funding_for_spot(tmp_path, capsys):
mod = load_module()
output_path = tmp_path / "purr-usdc.json"
def fake_post_info(payload):
if payload["type"] == "candleSnapshot":
return [{"t": 1000, "o": "1", "h": "1.2", "l": "0.9", "c": "1.1", "v": "100", "n": 10}]
raise AssertionError(f"Unexpected payload: {payload}")
with patch.object(mod, "_post_info", side_effect=fake_post_info):
exit_code = mod.main(
[
"export",
"PURR/USDC",
"--end-time-ms",
"5000",
"--output",
str(output_path),
"--json",
]
)
stdout = capsys.readouterr().out
rendered = json.loads(stdout)
saved = json.loads(output_path.read_text(encoding="utf-8"))
assert exit_code == 0
assert rendered["summary"]["funding_count"] == 0
assert saved["source"]["market_type"] == "spot"
assert saved["funding_history"] == []
+426
View File
@@ -0,0 +1,426 @@
"""Tests for optional-skills/productivity/memento-flashcards/scripts/memento_cards.py"""
import csv
import json
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
import pytest
# Add the scripts dir so we can import the module directly
SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "optional-skills" / "productivity" / "memento-flashcards" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import memento_cards
@pytest.fixture(autouse=True)
def isolated_data(tmp_path, monkeypatch):
"""Redirect card storage to a temp directory for every test."""
data_dir = tmp_path / "data"
data_dir.mkdir()
monkeypatch.setattr(memento_cards, "DATA_DIR", data_dir)
monkeypatch.setattr(memento_cards, "CARDS_FILE", data_dir / "cards.json")
return data_dir
def _run(capsys, argv: list[str]) -> dict:
"""Run main() with given argv and return parsed JSON output."""
with mock.patch("sys.argv", ["memento_cards"] + argv):
memento_cards.main()
captured = capsys.readouterr()
return json.loads(captured.out)
# ── Add / List / Delete ──────────────────────────────────────────────────────
class TestCardCRUD:
def test_add_creates_card(self, capsys):
result = _run(capsys, ["add", "--question", "What is 2+2?", "--answer", "4", "--collection", "Math"])
assert result["ok"] is True
card = result["card"]
assert card["question"] == "What is 2+2?"
assert card["answer"] == "4"
assert card["collection"] == "Math"
assert card["status"] == "learning"
assert card["ease_streak"] == 0
uuid.UUID(card["id"]) # validates it's a real UUID
def test_add_default_collection(self, capsys):
result = _run(capsys, ["add", "--question", "Q?", "--answer", "A"])
assert result["card"]["collection"] == "General"
def test_list_all(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "C2"])
result = _run(capsys, ["list"])
assert result["count"] == 2
def test_list_by_collection(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "C2"])
result = _run(capsys, ["list", "--collection", "C1"])
assert result["count"] == 1
assert result["cards"][0]["collection"] == "C1"
def test_list_by_status(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1"])
result = _run(capsys, ["list", "--status", "learning"])
assert result["count"] == 1
result = _run(capsys, ["list", "--status", "retired"])
assert result["count"] == 0
def test_delete_card(self, capsys):
result = _run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = result["card"]["id"]
del_result = _run(capsys, ["delete", "--id", card_id])
assert del_result["ok"] is True
assert del_result["deleted"] == card_id
# Verify gone
list_result = _run(capsys, ["list"])
assert list_result["count"] == 0
def test_delete_nonexistent(self, capsys):
with pytest.raises(SystemExit):
_run(capsys, ["delete", "--id", "nonexistent"])
def test_delete_collection(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "ToDelete"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "ToDelete"])
_run(capsys, ["add", "--question", "Q3", "--answer", "A3", "--collection", "Keep"])
result = _run(capsys, ["delete-collection", "--collection", "ToDelete"])
assert result["ok"] is True
assert result["deleted_count"] == 2
list_result = _run(capsys, ["list"])
assert list_result["count"] == 1
assert list_result["cards"][0]["collection"] == "Keep"
# ── Due Filtering ────────────────────────────────────────────────────────────
class TestDueFiltering:
def test_new_card_is_due(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
result = _run(capsys, ["due"])
assert result["count"] == 1
def test_future_card_not_due(self, capsys, monkeypatch):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
# Rate it good (pushes next_review_at to +3 days)
card_id = _run(capsys, ["list"])["cards"][0]["id"]
_run(capsys, ["rate", "--id", card_id, "--rating", "good"])
result = _run(capsys, ["due"])
assert result["count"] == 0
def test_retired_card_not_due(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
_run(capsys, ["rate", "--id", card_id, "--rating", "retire"])
result = _run(capsys, ["due"])
assert result["count"] == 0
def test_due_with_collection_filter(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "C2"])
result = _run(capsys, ["due", "--collection", "C1"])
assert result["count"] == 1
assert result["cards"][0]["collection"] == "C1"
# ── Rating and Rescheduling ──────────────────────────────────────────────────
class TestRating:
def test_hard_adds_1_day(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
before = datetime.now(timezone.utc)
result = _run(capsys, ["rate", "--id", card_id, "--rating", "hard"])
after = datetime.now(timezone.utc)
next_review = datetime.fromisoformat(result["card"]["next_review_at"])
assert before + timedelta(days=1) <= next_review <= after + timedelta(days=1)
assert result["card"]["ease_streak"] == 0
def test_good_adds_3_days(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
before = datetime.now(timezone.utc)
result = _run(capsys, ["rate", "--id", card_id, "--rating", "good"])
next_review = datetime.fromisoformat(result["card"]["next_review_at"])
assert next_review >= before + timedelta(days=3)
assert result["card"]["ease_streak"] == 0
def test_easy_adds_7_days_and_increments_streak(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
result = _run(capsys, ["rate", "--id", card_id, "--rating", "easy"])
assert result["card"]["ease_streak"] == 1
assert result["card"]["status"] == "learning"
def test_retire_sets_retired(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
result = _run(capsys, ["rate", "--id", card_id, "--rating", "retire"])
assert result["card"]["status"] == "retired"
assert result["card"]["ease_streak"] == 0
def test_auto_retire_after_3_easys(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
# Force card to be due by manipulating next_review_at through rate
for i in range(3):
# Load and directly set next_review_at to now so it's ratable
data = memento_cards._load()
for c in data["cards"]:
if c["id"] == card_id:
c["next_review_at"] = memento_cards._iso(memento_cards._now())
memento_cards._save(data)
result = _run(capsys, ["rate", "--id", card_id, "--rating", "easy"])
assert result["card"]["ease_streak"] == 3
assert result["card"]["status"] == "retired"
def test_hard_resets_ease_streak(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
# Easy twice
for _ in range(2):
data = memento_cards._load()
for c in data["cards"]:
if c["id"] == card_id:
c["next_review_at"] = memento_cards._iso(memento_cards._now())
memento_cards._save(data)
_run(capsys, ["rate", "--id", card_id, "--rating", "easy"])
# Verify streak is 2
check = _run(capsys, ["list"])
assert check["cards"][0]["ease_streak"] == 2
# Hard resets
data = memento_cards._load()
for c in data["cards"]:
if c["id"] == card_id:
c["next_review_at"] = memento_cards._iso(memento_cards._now())
memento_cards._save(data)
result = _run(capsys, ["rate", "--id", card_id, "--rating", "hard"])
assert result["card"]["ease_streak"] == 0
assert result["card"]["status"] == "learning"
def test_rate_nonexistent_card(self, capsys):
with pytest.raises(SystemExit):
_run(capsys, ["rate", "--id", "nonexistent", "--rating", "easy"])
# ── CSV Export/Import ────────────────────────────────────────────────────────
class TestCSV:
def test_export_import_roundtrip(self, capsys, tmp_path):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "C2"])
csv_path = str(tmp_path / "export.csv")
result = _run(capsys, ["export", "--output", csv_path])
assert result["ok"] is True
assert result["exported"] == 2
# Verify CSV content
with open(csv_path, "r") as f:
reader = csv.reader(f)
rows = list(reader)
assert len(rows) == 2
assert rows[0] == ["Q1", "A1", "C1"]
assert rows[1] == ["Q2", "A2", "C2"]
# Delete all and reimport
data = memento_cards._load()
data["cards"] = []
memento_cards._save(data)
result = _run(capsys, ["import", "--file", csv_path, "--collection", "Fallback"])
assert result["ok"] is True
assert result["imported"] == 2
# Verify imported cards use CSV collection column
list_result = _run(capsys, ["list"])
collections = {c["collection"] for c in list_result["cards"]}
assert collections == {"C1", "C2"}
def test_import_without_collection_column(self, capsys, tmp_path):
csv_path = str(tmp_path / "no_col.csv")
with open(csv_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["Q1", "A1"])
writer.writerow(["Q2", "A2"])
result = _run(capsys, ["import", "--file", csv_path, "--collection", "MyDeck"])
assert result["imported"] == 2
list_result = _run(capsys, ["list"])
assert all(c["collection"] == "MyDeck" for c in list_result["cards"])
def test_import_skips_empty_rows(self, capsys, tmp_path):
csv_path = str(tmp_path / "sparse.csv")
with open(csv_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["Q1", "A1"])
writer.writerow(["", ""]) # empty
writer.writerow(["Q2"]) # only one column
writer.writerow(["Q3", "A3"])
result = _run(capsys, ["import", "--file", csv_path, "--collection", "Test"])
assert result["imported"] == 2
def test_import_nonexistent_file(self, capsys, tmp_path):
with pytest.raises(SystemExit):
_run(capsys, ["import", "--file", str(tmp_path / "nope.csv"), "--collection", "X"])
# ── Quiz Batch Add ───────────────────────────────────────────────────────────
class TestQuizBatchAdd:
def test_add_quiz_creates_cards(self, capsys):
questions = json.dumps([
{"question": "Q1?", "answer": "A1"},
{"question": "Q2?", "answer": "A2"},
])
result = _run(capsys, ["add-quiz", "--video-id", "abc123", "--questions", questions, "--collection", "Quiz - Test"])
assert result["ok"] is True
assert result["created_count"] == 2
for card in result["cards"]:
assert card["video_id"] == "abc123"
assert card["collection"] == "Quiz - Test"
def test_add_quiz_deduplicates_by_video_id(self, capsys):
questions = json.dumps([{"question": "Q?", "answer": "A"}])
_run(capsys, ["add-quiz", "--video-id", "dup1", "--questions", questions])
result = _run(capsys, ["add-quiz", "--video-id", "dup1", "--questions", questions])
assert result["ok"] is True
assert result["skipped"] is True
assert result["reason"] == "duplicate_video_id"
# Only 1 card total (not 2)
list_result = _run(capsys, ["list"])
assert list_result["count"] == 1
def test_add_quiz_invalid_json(self, capsys):
with pytest.raises(SystemExit):
_run(capsys, ["add-quiz", "--video-id", "x", "--questions", "not json"])
# ── Statistics ───────────────────────────────────────────────────────────────
class TestStats:
def test_stats_empty(self, capsys):
result = _run(capsys, ["stats"])
assert result["total"] == 0
assert result["learning"] == 0
assert result["retired"] == 0
assert result["due_now"] == 0
def test_stats_counts(self, capsys):
_run(capsys, ["add", "--question", "Q1", "--answer", "A1", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q2", "--answer", "A2", "--collection", "C1"])
_run(capsys, ["add", "--question", "Q3", "--answer", "A3", "--collection", "C2"])
# Retire one
card_id = _run(capsys, ["list"])["cards"][0]["id"]
_run(capsys, ["rate", "--id", card_id, "--rating", "retire"])
result = _run(capsys, ["stats"])
assert result["total"] == 3
assert result["learning"] == 2
assert result["retired"] == 1
assert result["due_now"] == 2 # 2 learning cards still due
assert result["collections"] == {"C1": 2, "C2": 1}
# ── Edge Cases ───────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_empty_deck_operations(self, capsys):
"""Operations on empty deck shouldn't crash."""
result = _run(capsys, ["due"])
assert result["count"] == 0
result = _run(capsys, ["list"])
assert result["count"] == 0
result = _run(capsys, ["stats"])
assert result["total"] == 0
def test_corrupt_json_recovery(self, capsys):
"""Corrupt JSON file should be treated as empty."""
memento_cards.DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(memento_cards.CARDS_FILE, "w") as f:
f.write("{corrupted json...")
result = _run(capsys, ["list"])
assert result["count"] == 0
# Can still add
result = _run(capsys, ["add", "--question", "Q", "--answer", "A"])
assert result["ok"] is True
def test_missing_cards_key_recovery(self, capsys):
"""JSON without 'cards' key should be treated as empty."""
memento_cards.DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(memento_cards.CARDS_FILE, "w") as f:
json.dump({"version": 1}, f)
result = _run(capsys, ["list"])
assert result["count"] == 0
def test_atomic_write_creates_dir(self, capsys):
"""Data dir is created automatically if missing."""
import shutil
if memento_cards.DATA_DIR.exists():
shutil.rmtree(memento_cards.DATA_DIR)
result = _run(capsys, ["add", "--question", "Q", "--answer", "A"])
assert result["ok"] is True
assert memento_cards.CARDS_FILE.exists()
def test_delete_collection_empty(self, capsys):
"""Deleting a nonexistent collection succeeds with 0 deleted."""
result = _run(capsys, ["delete-collection", "--collection", "Nope"])
assert result["ok"] is True
assert result["deleted_count"] == 0
# ── User Answer Tracking ────────────────────────────────────────────────────
class TestUserAnswer:
def test_rate_stores_user_answer(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
result = _run(capsys, ["rate", "--id", card_id, "--rating", "easy",
"--user-answer", "my answer"])
assert result["card"]["last_user_answer"] == "my answer"
def test_rate_without_user_answer_keeps_null(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
result = _run(capsys, ["rate", "--id", card_id, "--rating", "easy"])
assert result["card"]["last_user_answer"] is None
def test_new_card_has_last_user_answer_null(self, capsys):
result = _run(capsys, ["add", "--question", "Q", "--answer", "A"])
assert result["card"]["last_user_answer"] is None
def test_user_answer_persists_in_list(self, capsys):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
_run(capsys, ["rate", "--id", card_id, "--rating", "easy",
"--user-answer", "my answer"])
result = _run(capsys, ["list"])
assert result["cards"][0]["last_user_answer"] == "my answer"
def test_export_excludes_user_answer(self, capsys, tmp_path):
_run(capsys, ["add", "--question", "Q", "--answer", "A"])
card_id = _run(capsys, ["list"])["cards"][0]["id"]
_run(capsys, ["rate", "--id", card_id, "--rating", "easy",
"--user-answer", "my answer"])
csv_path = str(tmp_path / "export.csv")
_run(capsys, ["export", "--output", csv_path])
with open(csv_path) as f:
rows = list(csv.reader(f))
# CSV stays 3-column (question, answer, collection) — user_answer is internal only
assert len(rows[0]) == 3
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
"""Tests for the OpenClaw→Hermes migration hardening features.
Covers the changes in the "claw migrate hardening" PR:
- secret redaction (engine-level, applied to report JSON)
- warnings[] / next_steps[] on the report
- blocked-by-earlier-conflict sequencing for config.yaml mutations
- --json output mode on the migration script
- enum-like constants and ItemResult.sensitive field
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "optional-skills"
/ "migration"
/ "openclaw-migration"
/ "scripts"
/ "openclaw_to_hermes.py"
)
def _load():
spec = importlib.util.spec_from_file_location("openclaw_to_hermes_hard", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
# ───────────────────────────────────────────────────────────────────────
# Redaction
# ───────────────────────────────────────────────────────────────────────
def test_redact_replaces_secret_by_key_name():
mod = _load()
out = mod.redact_migration_value({"OPENROUTER_API_KEY": "sk-or-v1-abcdef12345678"})
assert out["OPENROUTER_API_KEY"] == mod.REDACTED_MIGRATION_VALUE
def test_redact_replaces_secret_by_value_pattern():
mod = _load()
# Even under a non-secret-looking key, the sk-... pattern should be replaced inline.
out = mod.redact_migration_value({"note": "use sk-or-v1-9Xs7fF2JkLmNpQrT to authenticate"})
assert "sk-or-" not in out["note"]
assert mod.REDACTED_MIGRATION_VALUE in out["note"]
def test_redact_handles_github_token_pattern():
mod = _load()
out = mod.redact_migration_value({"detail": "token: ghp_1234567890abcdef1234"})
assert "ghp_" not in out["detail"]
assert mod.REDACTED_MIGRATION_VALUE in out["detail"]
def test_redact_handles_slack_token_pattern():
mod = _load()
out = mod.redact_migration_value("xoxb-1234567890-abcdef")
assert out == mod.REDACTED_MIGRATION_VALUE
def test_redact_handles_google_api_key_pattern():
mod = _load()
out = mod.redact_migration_value("AIzaSyA-abc123def456ghi")
# Google key is a prefix — whole value is scrubbed
assert "AIza" not in out
def test_redact_handles_bearer_header():
mod = _load()
out = mod.redact_migration_value({"hint": "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc"})
# Key "hint" is not a secret marker — only the Bearer <token> substring
# gets scrubbed inline by the value pattern.
assert "Bearer eyJ" not in out["hint"]
assert mod.REDACTED_MIGRATION_VALUE in out["hint"]
def test_redact_is_recursive():
mod = _load()
nested = {
"outer": {
"items": [
{"password": "hunter2"},
{"details": {"apiKey": "my-key"}},
],
},
}
out = mod.redact_migration_value(nested)
assert out["outer"]["items"][0]["password"] == mod.REDACTED_MIGRATION_VALUE
assert out["outer"]["items"][1]["details"]["apiKey"] == mod.REDACTED_MIGRATION_VALUE
def test_redact_preserves_non_secret_keys_and_values():
mod = _load()
input_data = {"name": "hermes", "count": 42, "tags": ["a", "b"]}
out = mod.redact_migration_value(input_data)
assert out == input_data
def test_redact_normalizes_key_case_and_punctuation():
mod = _load()
# "Api Key", "api-key", "API_KEY" all normalize the same way.
for key in ("Api Key", "api-key", "API_KEY", "apikey"):
out = mod.redact_migration_value({key: "secret"})
assert out[key] == mod.REDACTED_MIGRATION_VALUE, f"failed to redact: {key}"
def test_redact_leaves_env_secretref_alone():
"""SecretRef-like shapes ({source: env, id: ...}) are pointers, not secrets."""
mod = _load()
ref = {"source": "env", "id": "OPENAI_API_KEY"}
out = mod.redact_migration_value({"apiKey": ref})
# The key "apiKey" itself triggers redaction today — this test locks that in.
# If we later want to exempt SecretRef values the way OpenClaw does, update
# both this test and _redact_internal together.
assert out["apiKey"] == mod.REDACTED_MIGRATION_VALUE
def test_write_report_redacts_api_keys_on_disk(tmp_path):
mod = _load()
report = {
"timestamp": "20260427T120000",
"mode": "execute",
"source_root": "/src",
"target_root": "/tgt",
"summary": {"migrated": 1, "conflict": 0, "error": 0, "skipped": 0, "archived": 0},
"items": [
{
"kind": "provider-keys",
"source": "openclaw.json",
"destination": "/tgt/.env",
"status": "migrated",
"reason": "",
"details": {"OPENROUTER_API_KEY": "sk-or-v1-1234567890abcdef"},
},
],
}
mod.write_report(tmp_path, report)
persisted = json.loads((tmp_path / "report.json").read_text())
# The raw secret must not appear anywhere in the persisted JSON.
assert "sk-or-v1-1234567890abcdef" not in (tmp_path / "report.json").read_text()
assert persisted["items"][0]["details"]["OPENROUTER_API_KEY"] == mod.REDACTED_MIGRATION_VALUE
# ───────────────────────────────────────────────────────────────────────
# Warnings and next-steps
# ───────────────────────────────────────────────────────────────────────
def _make_minimal_migrator(mod, tmp_path, **overrides):
source = tmp_path / "openclaw"
source.mkdir()
# Minimal valid OpenClaw layout so the Migrator constructor doesn't choke.
(source / "openclaw.json").write_text("{}", encoding="utf-8")
target = tmp_path / "hermes"
target.mkdir()
defaults = dict(
source_root=source,
target_root=target,
execute=False,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=None,
selected_options=set(),
)
defaults.update(overrides)
return mod.Migrator(**defaults)
def test_dry_run_report_includes_rerun_next_step(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path)
report = migrator.migrate()
steps = report["next_steps"]
assert any("dry-run" in step.lower() or "re-run" in step.lower() for step in steps)
def test_conflict_produces_overwrite_warning(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
# Inject a conflict on a config.yaml target to exercise the warning pathway.
migrator.record(
"tts-config",
source=None,
destination=migrator.target_root / "config.yaml",
status=mod.STATUS_CONFLICT,
reason="TTS already configured",
)
report = migrator.build_report()
assert any("--overwrite" in w for w in report["warnings"])
# The conflict on config.yaml should have flipped the block flag too.
assert migrator._config_apply_blocked is True
def test_error_produces_inspect_warning(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
migrator.record("mcp-servers", None, None, mod.STATUS_ERROR, "Bad YAML")
report = migrator.build_report()
assert any("failed" in w.lower() for w in report["warnings"])
def test_provider_keys_skipped_warning_when_secrets_disabled(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True, migrate_secrets=False)
migrator.record(
"provider-keys",
source=None,
destination=None,
status=mod.STATUS_SKIPPED,
reason="--migrate-secrets not set",
)
report = migrator.build_report()
assert any("--migrate-secrets" in w for w in report["warnings"])
# ───────────────────────────────────────────────────────────────────────
# Blocked-by-earlier-conflict sequencing
# ───────────────────────────────────────────────────────────────────────
def test_config_apply_block_flips_on_config_yaml_conflict(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
assert migrator._config_apply_blocked is False
migrator.record(
"model-config",
source=None,
destination=migrator.target_root / "config.yaml",
status=mod.STATUS_CONFLICT,
)
assert migrator._config_apply_blocked is True
def test_config_apply_block_flips_on_config_yaml_error(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
migrator.record(
"tts-config",
source=None,
destination=migrator.target_root / "config.yaml",
status=mod.STATUS_ERROR,
reason="YAML write failed",
)
assert migrator._config_apply_blocked is True
def test_config_apply_block_does_not_flip_on_non_config_conflict(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path, execute=True)
migrator.record(
"skill",
source=None,
destination=migrator.target_root / "skills" / "foo" / "SKILL.md",
status=mod.STATUS_CONFLICT,
)
assert migrator._config_apply_blocked is False
def test_run_if_selected_skips_config_ops_after_block(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(
mod, tmp_path, execute=True, selected_options={"model-config", "tts-config"}
)
migrator._config_apply_blocked = True
called = []
migrator.run_if_selected("tts-config", lambda: called.append(True))
assert called == []
# The skipped record uses the blocked reason.
blocked = [i for i in migrator.items if i.kind == "tts-config"]
assert len(blocked) == 1
assert blocked[0].status == mod.STATUS_SKIPPED
assert blocked[0].reason == mod.REASON_BLOCKED_BY_APPLY_CONFLICT
def test_run_if_selected_runs_non_config_ops_even_after_block(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(
mod, tmp_path, execute=True, selected_options={"soul"}
)
migrator._config_apply_blocked = True
called = []
migrator.run_if_selected("soul", lambda: called.append(True))
assert called == [True]
def test_dry_run_never_blocks_even_after_conflict(tmp_path):
"""Dry runs must preview the full plan — blocking mid-preview would hide
conflicts and mislead the user about what would actually happen."""
mod = _load()
migrator = _make_minimal_migrator(
mod, tmp_path, execute=False, selected_options={"tts-config"}
)
migrator._config_apply_blocked = True
called = []
migrator.run_if_selected("tts-config", lambda: called.append(True))
assert called == [True]
# ───────────────────────────────────────────────────────────────────────
# --json output mode
# ───────────────────────────────────────────────────────────────────────
def test_json_mode_emits_structured_report(tmp_path):
"""End-to-end: run the CLI with --json and no --execute, parse stdout."""
source = tmp_path / "openclaw"
source.mkdir()
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4"}}}),
encoding="utf-8",
)
target = tmp_path / "hermes"
target.mkdir()
result = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--source", str(source),
"--target", str(target),
"--json",
],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert "summary" in payload
assert "warnings" in payload
assert "next_steps" in payload
assert payload["mode"] == "dry-run"
def test_json_mode_redacts_secrets_in_output(tmp_path):
"""Even plan-only JSON output goes through the redactor — the stdout
capture path is what gets piped into CI / support tickets."""
source = tmp_path / "openclaw"
source.mkdir()
(source / "openclaw.json").write_text("{}", encoding="utf-8")
# Plant a fake OpenClaw .env with a recognizably-shaped key.
(source / ".env").write_text(
"OPENROUTER_API_KEY=sk-or-v1-abcdef1234567890abcdef\n", encoding="utf-8"
)
target = tmp_path / "hermes"
target.mkdir()
result = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--source", str(source),
"--target", str(target),
"--migrate-secrets", # so provider-keys surface in the plan
"--json",
],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
# The raw key value must never appear in the JSON output.
assert "sk-or-v1-abcdef1234567890abcdef" not in result.stdout
# ───────────────────────────────────────────────────────────────────────
# ItemResult schema additions
# ───────────────────────────────────────────────────────────────────────
def test_item_result_has_sensitive_field():
mod = _load()
item = mod.ItemResult(kind="x", source=None, destination=None, status="migrated")
assert item.sensitive is False
def test_record_honors_sensitive_flag(tmp_path):
mod = _load()
migrator = _make_minimal_migrator(mod, tmp_path)
migrator.record("x", None, None, "migrated", sensitive=True)
assert migrator.items[0].sensitive is True
def test_status_constants_match_historical_strings():
"""Downstream consumers (claw.py, tests, docs) depend on these string values."""
mod = _load()
assert mod.STATUS_MIGRATED == "migrated"
assert mod.STATUS_SKIPPED == "skipped"
assert mod.STATUS_CONFLICT == "conflict"
assert mod.STATUS_ERROR == "error"
assert mod.STATUS_ARCHIVED == "archived"
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "optional-skills"
/ "productivity"
/ "telephony"
/ "scripts"
/ "telephony.py"
)
def load_module():
spec = importlib.util.spec_from_file_location("telephony_skill", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_save_twilio_writes_env_and_state(tmp_path: Path, monkeypatch):
mod = load_module()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
result = mod.save_twilio(
"AC123",
"secret-token",
phone_number="+1 (702) 555-1234",
phone_sid="PN123",
)
env_text = (tmp_path / ".hermes" / ".env").read_text(encoding="utf-8")
state = json.loads((tmp_path / ".hermes" / "telephony_state.json").read_text(encoding="utf-8"))
assert result["success"] is True
assert "TWILIO_ACCOUNT_SID=AC123" in env_text
assert "TWILIO_AUTH_TOKEN=secret-token" in env_text
assert "TWILIO_PHONE_NUMBER=+17025551234" in env_text
assert "TWILIO_PHONE_NUMBER_SID=PN123" in env_text
assert state["twilio"]["default_phone_number"] == "+17025551234"
assert state["twilio"]["default_phone_sid"] == "PN123"
def test_upsert_env_updates_existing_values(tmp_path: Path):
mod = load_module()
env_path = tmp_path / ".env"
env_path.write_text("TWILIO_PHONE_NUMBER=+15550000000\nOTHER=keep\n", encoding="utf-8")
mod._upsert_env_file(
{
"TWILIO_PHONE_NUMBER": "+15551112222",
"TWILIO_PHONE_NUMBER_SID": "PN999",
},
env_path=env_path,
)
env_text = env_path.read_text(encoding="utf-8")
assert "TWILIO_PHONE_NUMBER=+15551112222" in env_text
assert "TWILIO_PHONE_NUMBER_SID=PN999" in env_text
assert "OTHER=keep" in env_text
def test_messages_after_checkpoint_returns_only_newer_items():
mod = load_module()
messages = [
{"sid": "SM3", "body": "newest"},
{"sid": "SM2", "body": "middle"},
{"sid": "SM1", "body": "oldest"},
]
assert mod._messages_after_checkpoint(messages, "") == messages
assert mod._messages_after_checkpoint(messages, "SM2") == [{"sid": "SM3", "body": "newest"}]
assert mod._messages_after_checkpoint(messages, "SM3") == []
def test_twilio_buy_number_saves_env_and_state(tmp_path: Path):
mod = load_module()
state_path = tmp_path / "telephony_state.json"
env_path = tmp_path / ".env"
mod._twilio_request = lambda method, path, params=None, form=None: {
"sid": "PN111",
"phone_number": "+17025550123",
"friendly_name": "Test Number",
"capabilities": {"voice": True, "sms": True},
}
result = mod._twilio_buy_number(
"+17025550123",
save_env=True,
state_path=state_path,
env_path=env_path,
)
state = json.loads(state_path.read_text(encoding="utf-8"))
env_text = env_path.read_text(encoding="utf-8")
assert result["phone_sid"] == "PN111"
assert state["twilio"]["default_phone_number"] == "+17025550123"
assert state["twilio"]["default_phone_sid"] == "PN111"
assert "TWILIO_PHONE_NUMBER=+17025550123" in env_text
assert "TWILIO_PHONE_NUMBER_SID=PN111" in env_text
def test_twilio_inbox_marks_seen_checkpoint(tmp_path: Path):
mod = load_module()
state_path = tmp_path / "telephony_state.json"
mod._save_state(
{
"version": 1,
"twilio": {
"default_phone_number": "+17025550123",
"default_phone_sid": "PN111",
"last_inbound_message_sid": "SM1",
},
},
state_path,
)
mod._twilio_owned_numbers = lambda limit=50: [
mod.OwnedTwilioNumber(
sid="PN111",
phone_number="+17025550123",
friendly_name="Main",
capabilities={"voice": True, "sms": True},
)
]
mod._twilio_request = lambda method, path, params=None, form=None: {
"messages": [
{
"sid": "SM3",
"direction": "inbound",
"status": "received",
"from": "+15551230000",
"to": "+17025550123",
"date_sent": "Tue, 14 Mar 2026 09:00:00 +0000",
"body": "new message",
"num_media": "0",
},
{
"sid": "SM1",
"direction": "inbound",
"status": "received",
"from": "+15551110000",
"to": "+17025550123",
"date_sent": "Tue, 14 Mar 2026 08:00:00 +0000",
"body": "old message",
"num_media": "0",
},
]
}
result = mod._twilio_inbox(limit=10, since_last=True, mark_seen=True, state_path=state_path)
state = json.loads(state_path.read_text(encoding="utf-8"))
assert result["count"] == 1
assert result["messages"][0]["sid"] == "SM3"
assert state["twilio"]["last_inbound_message_sid"] == "SM3"
def test_vapi_import_twilio_number_saves_phone_number_id(tmp_path: Path):
mod = load_module()
state_path = tmp_path / "telephony_state.json"
env_path = tmp_path / ".env"
mod._vapi_api_key = lambda: "vapi-key"
mod._twilio_creds = lambda: ("AC123", "token123")
mod._resolve_twilio_number = lambda identifier=None: mod.OwnedTwilioNumber(
sid="PN111",
phone_number="+17025550123",
friendly_name="Main",
capabilities={"voice": True, "sms": True},
)
mod._json_request = lambda method, url, headers=None, params=None, form=None, json_body=None: {
"id": "vapi-phone-xyz"
}
result = mod._vapi_import_twilio_number(
save_env=True,
state_path=state_path,
env_path=env_path,
)
state = json.loads(state_path.read_text(encoding="utf-8"))
env_text = env_path.read_text(encoding="utf-8")
assert result["phone_number_id"] == "vapi-phone-xyz"
assert state["vapi"]["phone_number_id"] == "vapi-phone-xyz"
assert "VAPI_PHONE_NUMBER_ID=vapi-phone-xyz" in env_text
def test_diagnose_includes_decision_tree_and_saved_state(tmp_path: Path, monkeypatch):
mod = load_module()
hermes_home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mod._save_state(
{
"version": 1,
"twilio": {
"default_phone_number": "+17025550123",
"last_inbound_message_sid": "SM123",
},
"vapi": {
"phone_number_id": "vapi-abc",
},
},
hermes_home / "telephony_state.json",
)
(hermes_home / ".env").parent.mkdir(parents=True, exist_ok=True)
(hermes_home / ".env").write_text(
"TWILIO_ACCOUNT_SID=AC123\nTWILIO_AUTH_TOKEN=token\nBLAND_API_KEY=bland\n",
encoding="utf-8",
)
result = mod.diagnose()
assert result["providers"]["twilio"]["default_phone_number"] == "+17025550123"
assert result["providers"]["twilio"]["last_inbound_message_sid"] == "SM123"
assert result["providers"]["bland"]["configured"] is True
assert result["providers"]["vapi"]["phone_number_id"] == "vapi-abc"
assert any(item["use"] == "Twilio" for item in result["decision_tree"])
@@ -0,0 +1,29 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SKILL_MD = REPO_ROOT / "skills" / "social-media" / "xurl" / "SKILL.md"
DOC_MD = (
REPO_ROOT
/ "website"
/ "docs"
/ "user-guide"
/ "skills"
/ "bundled"
/ "social-media"
/ "social-media-xurl.md"
)
def test_xurl_article_ingestion_uses_raw_api_mode():
skill_text = SKILL_MD.read_text(encoding="utf-8")
docs_text = DOC_MD.read_text(encoding="utf-8")
for text in (skill_text, docs_text):
assert "For X Articles, use raw API mode" in text
assert "`xurl read`" in text
assert "do not put `read` before a `/2/tweets/...`" in text
assert "tweet.fields=created_at,lang,public_metrics" in text
assert "referenced_tweets,article" in text
assert "data.article.plain_text" in text
assert "read '/2/tweets/" not in text
+127
View File
@@ -0,0 +1,127 @@
"""Tests for optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py"""
import json
import sys
from pathlib import Path
from unittest import mock
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "optional-skills" / "productivity" / "memento-flashcards" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import youtube_quiz
def _run(capsys, argv: list[str]) -> dict:
"""Run main() with given argv and return parsed JSON output."""
with mock.patch("sys.argv", ["youtube_quiz"] + argv):
youtube_quiz.main()
captured = capsys.readouterr()
return json.loads(captured.out)
class TestNormalizeSegments:
def test_basic(self):
segments = [{"text": "hello "}, {"text": " world"}]
assert youtube_quiz._normalize_segments(segments) == "hello world"
def test_empty_segments(self):
assert youtube_quiz._normalize_segments([]) == ""
def test_whitespace_only(self):
assert youtube_quiz._normalize_segments([{"text": " "}, {"text": " "}]) == ""
def test_collapses_multiple_spaces(self):
segments = [{"text": "a b"}, {"text": "c d"}]
assert youtube_quiz._normalize_segments(segments) == "a b c d"
class TestFetchMissingDependency:
def test_missing_youtube_transcript_api(self, capsys, monkeypatch):
"""When youtube-transcript-api is not installed, report the error."""
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "youtube_transcript_api":
raise ImportError("No module named 'youtube_transcript_api'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
with pytest.raises(SystemExit) as exc_info:
_run(capsys, ["fetch", "test123"])
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["ok"] is False
assert result["error"] == "missing_dependency"
assert "pip install" in result["message"]
class TestFetchWithMockedAPI:
def _make_mock_module(self, segments=None, raise_exc=None):
"""Create a mock youtube_transcript_api module."""
mock_module = mock.MagicMock()
mock_api_instance = mock.MagicMock()
mock_module.YouTubeTranscriptApi.return_value = mock_api_instance
if raise_exc:
mock_api_instance.fetch.side_effect = raise_exc
else:
raw_data = segments or [{"text": "Hello world"}]
result = mock.MagicMock()
result.to_raw_data.return_value = raw_data
mock_api_instance.fetch.return_value = result
return mock_module
def test_successful_fetch(self, capsys):
mock_mod = self._make_mock_module(
segments=[{"text": "This is a test"}, {"text": "transcript segment"}]
)
with mock.patch.dict("sys.modules", {"youtube_transcript_api": mock_mod}):
result = _run(capsys, ["fetch", "abc123"])
assert result["ok"] is True
assert result["video_id"] == "abc123"
assert "This is a test" in result["transcript"]
assert "transcript segment" in result["transcript"]
def test_fetch_error(self, capsys):
mock_mod = self._make_mock_module(raise_exc=Exception("Video unavailable"))
with mock.patch.dict("sys.modules", {"youtube_transcript_api": mock_mod}):
with pytest.raises(SystemExit):
_run(capsys, ["fetch", "bad_id"])
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["ok"] is False
assert result["error"] == "transcript_unavailable"
def test_empty_transcript(self, capsys):
mock_mod = self._make_mock_module(segments=[{"text": ""}, {"text": " "}])
with mock.patch.dict("sys.modules", {"youtube_transcript_api": mock_mod}):
with pytest.raises(SystemExit):
_run(capsys, ["fetch", "empty_vid"])
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["ok"] is False
assert result["error"] == "empty_transcript"
def test_segments_without_to_raw_data(self, capsys):
"""Handle plain list segments (no to_raw_data method)."""
mock_mod = mock.MagicMock()
mock_api = mock.MagicMock()
mock_mod.YouTubeTranscriptApi.return_value = mock_api
# Return a plain list (no to_raw_data attribute)
mock_api.fetch.return_value = [{"text": "plain list"}]
with mock.patch.dict("sys.modules", {"youtube_transcript_api": mock_mod}):
result = _run(capsys, ["fetch", "plain123"])
assert result["ok"] is True
assert result["transcript"] == "plain list"