forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -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
|
||||
@@ -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]"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"] == []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user