Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Smoke tests for the actual-setup optional skill.
|
||||
|
||||
Validates:
|
||||
- SKILL.md frontmatter conforms to the ≤60-char description standard
|
||||
- Frontmatter has required fields
|
||||
- The skill references the first-class ``actual`` provider (not the
|
||||
legacy custom-provider config path that conflicts with it)
|
||||
- The bundled OpenCode reference exists
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "devops"
|
||||
/ "actual-setup"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_source() -> str:
|
||||
return (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frontmatter(skill_source) -> dict:
|
||||
m = re.search(r"^---\n(.*?)\n---", skill_source, 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_description_under_60_chars(frontmatter) -> None:
|
||||
desc = frontmatter["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars (limit ≤60): {desc!r}"
|
||||
|
||||
|
||||
def test_has_required_frontmatter_fields(frontmatter) -> None:
|
||||
for field in ("name", "description", "version", "license"):
|
||||
assert field in frontmatter, f"missing required field: {field}"
|
||||
|
||||
|
||||
def test_credits_contributor(frontmatter) -> None:
|
||||
assert "shl0ms" in str(frontmatter.get("author", "")), (
|
||||
"author must credit the human contributor first"
|
||||
)
|
||||
|
||||
|
||||
def test_uses_first_class_provider_not_custom_provider(skill_source) -> None:
|
||||
# The legacy setup configured Actual as providers.actual.* custom entries,
|
||||
# which now collides with the built-in provider of the same name.
|
||||
assert "--provider actual" in skill_source
|
||||
assert "hermes config set providers.actual.api " not in skill_source
|
||||
assert "key_env" not in skill_source
|
||||
|
||||
|
||||
def test_opencode_reference_exists() -> None:
|
||||
assert (SKILL_DIR / "references" / "opencode.md").is_file()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Contract checks for the optional agent-merge-conflict-arbiter skill asset.
|
||||
|
||||
Reads only the SKILL.md markdown asset — no .py source reads, no network.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL_PATH = (
|
||||
REPO_ROOT
|
||||
/ "optional-skills"
|
||||
/ "autonomous-ai-agents"
|
||||
/ "agent-merge-conflict-arbiter"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
REQUIRED_SECTIONS = [
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
]
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---"), "SKILL.md must open with frontmatter"
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm_text = content[3 : m.start() + 3]
|
||||
body = content[m.end() + 3 :]
|
||||
fm = {}
|
||||
for line in fm_text.splitlines():
|
||||
km = re.match(r"^(\w[\w-]*):\s*(.*)$", line)
|
||||
if km:
|
||||
fm[km.group(1)] = km.group(2).strip().strip('"')
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "agent-merge-conflict-arbiter"
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith("."), "description must end with a period"
|
||||
assert desc.count(".") == 1, "description must be one sentence"
|
||||
|
||||
|
||||
def test_required_sections_present_in_order():
|
||||
_, body = _frontmatter_and_body()
|
||||
positions = []
|
||||
for section in REQUIRED_SECTIONS:
|
||||
idx = body.find(section)
|
||||
assert idx != -1, f"missing section: {section}"
|
||||
positions.append(idx)
|
||||
assert positions == sorted(positions), "sections out of required order"
|
||||
|
||||
|
||||
def test_body_size_within_bundled_norms():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
lines = content.count("\n") + 1
|
||||
assert 80 <= lines <= 260, f"SKILL.md is {lines} lines; expected ~100-200"
|
||||
|
||||
|
||||
def test_references_native_hermes_tools():
|
||||
_, body = _frontmatter_and_body()
|
||||
for tool in ("`terminal`", "`read_file`", "`patch`", "`delegate_task`"):
|
||||
assert tool in body, f"body must reference native tool {tool}"
|
||||
|
||||
|
||||
def test_classification_taxonomy_present():
|
||||
_, body = _frontmatter_and_body()
|
||||
for cls in (
|
||||
"disjoint-intent",
|
||||
"same-question-different-answer",
|
||||
"superseded",
|
||||
):
|
||||
assert cls in body, f"missing hunk class: {cls}"
|
||||
|
||||
|
||||
def test_impartiality_contract_stated():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "never favor" in body.lower()
|
||||
assert "drive-by" in body.lower()
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
@@ -0,0 +1,149 @@
|
||||
"""CI enforcement of the skill authoring standards (AGENTS.md hardline).
|
||||
|
||||
Every bundled (skills/) and optional (optional-skills/) SKILL.md must satisfy
|
||||
the programmatically-checkable subset of the authoring standards. Judgment
|
||||
calls (tier placement, router-skill smell, prose quality) stay with review;
|
||||
everything here is mechanical.
|
||||
|
||||
Pre-existing violations that need non-trivial content work are grandfathered
|
||||
in the GRANDFATHER dict below. Do NOT add new entries for new skills — fix
|
||||
the skill instead. Remove entries as the debt is paid down.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MARKETING = re.compile(
|
||||
r"\b(powerful|comprehensive|seamless|revolutionary|cutting-edge|state-of-the-art)\b",
|
||||
re.I,
|
||||
)
|
||||
MACHINE_LOCAL = re.compile(r"/home/(?!runner\b)[a-z0-9_-]+/|[A-Z]:\\+Users\\+(?!<)")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grandfathered pre-existing debt. Shrink this list; never grow it.
|
||||
# ---------------------------------------------------------------------------
|
||||
GRANDFATHER: dict[str, set[str]] = {
|
||||
# (empty — the Aug 2026 sweep cleared all mechanical violations)
|
||||
}
|
||||
|
||||
|
||||
def _skill_paths():
|
||||
return sorted(
|
||||
list(REPO.glob("skills/**/SKILL.md"))
|
||||
+ list(REPO.glob("optional-skills/**/SKILL.md"))
|
||||
)
|
||||
|
||||
|
||||
def _rel(p: Path) -> str:
|
||||
return str(p.parent.relative_to(REPO))
|
||||
|
||||
|
||||
def _params():
|
||||
return [pytest.param(p, id=_rel(p)) for p in _skill_paths()]
|
||||
|
||||
|
||||
def _grandfathered(p: Path, rule: str) -> bool:
|
||||
return rule in GRANDFATHER.get(_rel(p), set())
|
||||
|
||||
|
||||
def _frontmatter(p: Path):
|
||||
content = p.read_text(encoding="utf-8")
|
||||
assert content.startswith("---"), f"{_rel(p)}: SKILL.md must start with ---"
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, f"{_rel(p)}: unclosed frontmatter"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
assert isinstance(fm, dict), f"{_rel(p)}: frontmatter must be a YAML mapping"
|
||||
return fm, content
|
||||
|
||||
|
||||
ALL_SKILL_NAMES = None
|
||||
|
||||
|
||||
def _all_names():
|
||||
global ALL_SKILL_NAMES
|
||||
if ALL_SKILL_NAMES is None:
|
||||
names = set()
|
||||
for p in _skill_paths():
|
||||
names.add(p.parent.name)
|
||||
ALL_SKILL_NAMES = names
|
||||
return ALL_SKILL_NAMES
|
||||
|
||||
|
||||
def test_at_least_the_expected_population():
|
||||
# sanity: the globs actually find the trees (not a count snapshot)
|
||||
paths = _skill_paths()
|
||||
assert any("optional-skills" in str(p) for p in paths)
|
||||
assert any(str(p.parent).startswith(str(REPO / "skills")) for p in paths)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_required_frontmatter_fields(p):
|
||||
fm, _ = _frontmatter(p)
|
||||
missing = [
|
||||
f
|
||||
for f in ("name", "description", "version", "author", "license", "platforms")
|
||||
if f not in fm
|
||||
]
|
||||
if missing and not _grandfathered(p, "fields"):
|
||||
pytest.fail(f"{_rel(p)}: missing frontmatter fields: {missing}")
|
||||
hermes = (fm.get("metadata") or {}).get("hermes") or {}
|
||||
if not (hermes.get("tags") or fm.get("tags")) and not _grandfathered(p, "tags"):
|
||||
pytest.fail(f"{_rel(p)}: no tags (metadata.hermes.tags or top-level tags)")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_name_matches_directory(p):
|
||||
fm, _ = _frontmatter(p)
|
||||
if fm.get("name") != p.parent.name and not _grandfathered(p, "name"):
|
||||
pytest.fail(
|
||||
f"{_rel(p)}: frontmatter name {fm.get('name')!r} != dir {p.parent.name!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_description_hardline(p):
|
||||
fm, _ = _frontmatter(p)
|
||||
desc = str(fm.get("description") or "")
|
||||
if _grandfathered(p, "description"):
|
||||
return
|
||||
assert len(desc) <= 60, f"{_rel(p)}: description {len(desc)} chars (hardline 60)"
|
||||
assert desc.rstrip().endswith("."), f"{_rel(p)}: description must end with a period"
|
||||
m = MARKETING.search(desc)
|
||||
assert not m, f"{_rel(p)}: marketing word in description: {m.group(0)!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_related_skills_resolve(p):
|
||||
fm, _ = _frontmatter(p)
|
||||
hermes = (fm.get("metadata") or {}).get("hermes") or {}
|
||||
dangling = [
|
||||
rs for rs in (hermes.get("related_skills") or []) if rs not in _all_names()
|
||||
]
|
||||
if dangling and not _grandfathered(p, "related"):
|
||||
pytest.fail(f"{_rel(p)}: dangling related_skills: {dangling}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_no_machine_local_paths(p):
|
||||
_, content = _frontmatter(p)
|
||||
m = MACHINE_LOCAL.search(content)
|
||||
if m and not _grandfathered(p, "paths"):
|
||||
pytest.fail(f"{_rel(p)}: machine-local path {m.group(0)!r}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("p", _params())
|
||||
def test_size_limit(p):
|
||||
_, content = _frontmatter(p)
|
||||
if len(content) > 100_000 and not _grandfathered(p, "size"):
|
||||
pytest.fail(
|
||||
f"{_rel(p)}: {len(content)} chars > 100k — split into references/"
|
||||
)
|
||||
|
||||
|
||||
def test_grandfather_entries_still_needed():
|
||||
"""A grandfather entry whose violation is fixed must be removed."""
|
||||
for rel in GRANDFATHER:
|
||||
assert (REPO / rel / "SKILL.md").exists(), f"stale grandfather entry: {rel}"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Durable integration contracts for the bundled Box productivity skill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL_DIR = REPO_ROOT / "skills" / "productivity" / "box"
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
from agent.skill_utils import parse_frontmatter
|
||||
|
||||
frontmatter, _ = parse_frontmatter(content)
|
||||
return frontmatter
|
||||
|
||||
|
||||
def _local_markdown_targets(path: Path) -> set[Path]:
|
||||
targets: set[Path] = set()
|
||||
for raw_target in re.findall(r"\[[^]]+\]\(([^)]+)\)", path.read_text(encoding="utf-8")):
|
||||
target = raw_target.split("#", maxsplit=1)[0].strip("<>")
|
||||
if not target or "://" in target or target.startswith("mailto:"):
|
||||
continue
|
||||
targets.add((path.parent / unquote(target)).resolve())
|
||||
return targets
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frontmatter(skill_text: str) -> dict:
|
||||
return _parse_frontmatter(skill_text)
|
||||
|
||||
|
||||
def test_skill_frontmatter_is_valid_and_discoverable(frontmatter: dict):
|
||||
assert frontmatter.get("name") == "box"
|
||||
description = frontmatter.get("description")
|
||||
assert isinstance(description, str) and description.strip()
|
||||
assert len(description) <= 60
|
||||
assert description.endswith(".")
|
||||
assert frontmatter.get("license") == "MIT"
|
||||
assert "Chris Kim" in str(frontmatter.get("author"))
|
||||
assert "iskysun96" in str(frontmatter.get("author"))
|
||||
|
||||
platforms = frontmatter.get("platforms")
|
||||
assert isinstance(platforms, list)
|
||||
assert {"linux", "macos", "windows"}.issubset(platforms)
|
||||
|
||||
|
||||
def test_box_command_is_declared_without_universal_credential_gate(frontmatter: dict):
|
||||
prerequisites = frontmatter.get("prerequisites") or {}
|
||||
assert "box" in prerequisites.get("commands", [])
|
||||
assert not prerequisites.get("env_vars")
|
||||
|
||||
def test_all_local_links_resolve_inside_the_skill():
|
||||
markdown_files = list(SKILL_DIR.rglob("*.md"))
|
||||
for source in markdown_files:
|
||||
for target in _local_markdown_targets(source):
|
||||
assert target.is_file(), f"broken link in {source.relative_to(SKILL_DIR)}: {target}"
|
||||
assert target.is_relative_to(SKILL_DIR.resolve()), (
|
||||
f"local link in {source.relative_to(SKILL_DIR)} escapes the skill: {target}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_reference_is_reachable_from_skill_entrypoint():
|
||||
entrypoint_targets = _local_markdown_targets(SKILL_MD)
|
||||
reference_files = set((SKILL_DIR / "references").glob("*.md"))
|
||||
assert reference_files <= entrypoint_targets
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for optional-skills/web-development/cloudflare-temporary-deploy/scripts/parse_deploy_output.py"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "web-development"
|
||||
/ "cloudflare-temporary-deploy"
|
||||
/ "scripts"
|
||||
)
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import parse_deploy_output as pdo
|
||||
|
||||
|
||||
CREATED = """\
|
||||
Continuing means you accept Cloudflare's Terms of Service and Privacy Policy.
|
||||
|
||||
Temporary account ready:
|
||||
Account: swift-otter (created)
|
||||
Claim within: 60 minutes
|
||||
Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_AAA
|
||||
|
||||
Uploaded my-worker
|
||||
Deployed my-worker triggers
|
||||
https://my-worker.swift-otter.workers.dev
|
||||
"""
|
||||
|
||||
REUSED = """\
|
||||
Temporary account ready:
|
||||
Account: swift-otter (reused)
|
||||
Claim within: 17 minutes
|
||||
Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_BBB
|
||||
Deployed my-worker triggers
|
||||
https://my-worker.swift-otter.workers.dev
|
||||
"""
|
||||
|
||||
NOT_LOGGED_IN = """\
|
||||
✘ [ERROR] You are not logged in.
|
||||
|
||||
To continue without logging in, rerun this command with `--temporary`.
|
||||
"""
|
||||
|
||||
AUTH_PRESENT_ERROR = """\
|
||||
✘ [ERROR] The --temporary flag cannot be used while Wrangler is authenticated.
|
||||
Run `wrangler logout` first, or remove CLOUDFLARE_API_TOKEN.
|
||||
"""
|
||||
|
||||
|
||||
class TestParseCreated:
|
||||
def test_live_url(self):
|
||||
assert pdo.parse(CREATED)["live_url"] == "https://my-worker.swift-otter.workers.dev"
|
||||
|
||||
def test_claim_url(self):
|
||||
assert (
|
||||
pdo.parse(CREATED)["claim_url"]
|
||||
== "https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_AAA"
|
||||
)
|
||||
|
||||
def test_account_and_state(self):
|
||||
r = pdo.parse(CREATED)
|
||||
assert r["account"] == "swift-otter"
|
||||
assert r["account_state"] == "created"
|
||||
|
||||
def test_expiry_and_deployed(self):
|
||||
r = pdo.parse(CREATED)
|
||||
assert r["expires_minutes"] == 60
|
||||
assert r["deployed"] is True
|
||||
|
||||
|
||||
class TestParseReused:
|
||||
def test_state_is_reused(self):
|
||||
assert pdo.parse(REUSED)["account_state"] == "reused"
|
||||
|
||||
|
||||
|
||||
|
||||
class TestNoDeploy:
|
||||
def test_not_logged_in_has_no_urls(self):
|
||||
r = pdo.parse(NOT_LOGGED_IN)
|
||||
assert r["live_url"] is None
|
||||
assert r["claim_url"] is None
|
||||
assert r["account"] is None
|
||||
assert r["deployed"] is False
|
||||
|
||||
def test_auth_present_error_has_no_urls(self):
|
||||
r = pdo.parse(AUTH_PRESENT_ERROR)
|
||||
assert r["live_url"] is None
|
||||
assert r["claim_url"] is None
|
||||
assert r["deployed"] is False
|
||||
|
||||
|
||||
class TestRealWorldOutput:
|
||||
"""Regression: real wrangler output uses tab-indent + multi-word account names."""
|
||||
|
||||
REAL = (
|
||||
"⛅️ wrangler 4.103.0\n"
|
||||
"Continuing means you accept Cloudflare's Terms of Service and Privacy Policy.\n"
|
||||
"Solving proof-of-work challenge…\n"
|
||||
"Temporary account ready:\n"
|
||||
"\tAccount: Serene Temple (created)\n"
|
||||
"\tClaim within: 60 minutes\n"
|
||||
"\tClaim URL: https://dash.cloudflare.com/claim-preview?claimToken=fxLzyAD-vlTzMQmClpg\n"
|
||||
"Total Upload: 0.19 KiB / gzip: 0.16 KiB\n"
|
||||
"Uploaded hermes-temp-hello (0.74 sec)\n"
|
||||
"Deployed hermes-temp-hello triggers (0.42 sec)\n"
|
||||
" https://hermes-temp-hello.serene-temple.workers.dev\n"
|
||||
)
|
||||
|
||||
def test_multiword_account_name(self):
|
||||
r = pdo.parse(self.REAL)
|
||||
assert r["account"] == "Serene Temple"
|
||||
assert r["account_state"] == "created"
|
||||
|
||||
def test_all_fields_from_real_output(self):
|
||||
r = pdo.parse(self.REAL)
|
||||
assert r["live_url"] == "https://hermes-temp-hello.serene-temple.workers.dev"
|
||||
assert r["claim_url"].endswith("claimToken=fxLzyAD-vlTzMQmClpg")
|
||||
assert r["expires_minutes"] == 60
|
||||
assert r["deployed"] is True
|
||||
|
||||
|
||||
class TestUrlHygiene:
|
||||
def test_trailing_punctuation_stripped(self):
|
||||
text = "Deployed\n see https://w.acct.workers.dev. for details"
|
||||
assert pdo.parse(text)["live_url"] == "https://w.acct.workers.dev"
|
||||
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_selftest_exits_zero(self):
|
||||
assert pdo.main(["--selftest"]) == 0
|
||||
|
||||
def test_main_prints_json_and_exit_zero_on_live(self, capsys):
|
||||
with mock.patch.object(sys.stdin, "read", return_value=CREATED):
|
||||
rc = pdo.main([])
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert rc == 0
|
||||
assert out["live_url"] == "https://my-worker.swift-otter.workers.dev"
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Invariant tests for the bundled comfyui skill.
|
||||
|
||||
Covers optional-skills/creative/comfyui — the diffusion workflow runner. Tests assert
|
||||
contracts (locale-independent file reads), not snapshots of skill content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent.parent
|
||||
SCRIPTS = REPO / "optional-skills" / "creative" / "comfyui" / "scripts"
|
||||
|
||||
# Text reads that must not depend on the host locale. The workflow and schema
|
||||
# JSON are user-authored files (exported by ComfyUI or hand-edited), so they
|
||||
# are read BOM-tolerantly: Notepad prepends U+FEFF, which makes json.load
|
||||
# raise JSONDecodeError. See the jobs.json regression in #66607. The /proc
|
||||
# reads are Linux-gated and never carry a BOM, so they pin plain utf-8.
|
||||
_ENCODING_SENSITIVE_READS = [
|
||||
("hardware_check.py", 'with open("/proc/version", "r", encoding="utf-8") as fh:'),
|
||||
("hardware_check.py", 'with open("/proc/meminfo", "r", encoding="utf-8") as fh:'),
|
||||
("run_workflow.py", 'with open(schema_path, encoding="utf-8-sig") as f:'),
|
||||
("run_workflow.py", 'with wf_path.open(encoding="utf-8-sig") as f:'),
|
||||
# Sibling call paths: every other script that parses a user-authored
|
||||
# workflow JSON reads it the same BOM-tolerant way (same bug class).
|
||||
("auto_fix_deps.py", 'wf_path.open(encoding="utf-8-sig")'),
|
||||
("check_deps.py", 'wf_path.open(encoding="utf-8-sig")'),
|
||||
("extract_schema.py", 'wf_path.open(encoding="utf-8-sig")'),
|
||||
("health_check.py", 'wf_path.open(encoding="utf-8-sig")'),
|
||||
("run_batch.py", 'wf_path.open(encoding="utf-8-sig")'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel_path,expected", _ENCODING_SENSITIVE_READS)
|
||||
def test_readers_are_locale_independent(rel_path, expected):
|
||||
"""Every text read of a user-supplied or system file pins its codec."""
|
||||
source = (SCRIPTS / rel_path).read_text(encoding="utf-8")
|
||||
assert expected in source, f"{rel_path}: locale-dependent read of a UTF-8 payload"
|
||||
|
||||
|
||||
def _run_under_c_locale(snippet: str) -> subprocess.CompletedProcess:
|
||||
"""Execute a snippet in a child interpreter forced to a non-UTF-8 locale.
|
||||
|
||||
The default text codec is resolved at interpreter startup, so the locale
|
||||
has to be set on the child's environment. Patching os.environ in-process
|
||||
would not change locale.getpreferredencoding(). PYTHONUTF8=0 disables
|
||||
PEP 540 UTF-8 mode, which would otherwise mask the bug entirely.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"LC_ALL": "C",
|
||||
"LANG": "C",
|
||||
"PYTHONUTF8": "0",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
})
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", snippet],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def test_load_schema_reads_non_ascii_under_non_utf8_locale(tmp_path):
|
||||
"""A schema with non-ASCII labels loads under the C locale.
|
||||
|
||||
Without the explicit encoding the C-locale default codec is ASCII, so
|
||||
json.load crashes with UnicodeDecodeError on any CJK/Cyrillic label.
|
||||
"""
|
||||
schema_path = tmp_path / "schema.json"
|
||||
schema_path.write_bytes(
|
||||
json.dumps(
|
||||
{"prompt": {"label": "プロンプト", "type": "string"}},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
result = _run_under_c_locale(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, {str(SCRIPTS)!r})
|
||||
from run_workflow import load_schema
|
||||
|
||||
schema = load_schema({str(schema_path)!r}, {{}})
|
||||
assert schema["prompt"]["label"] == "\\u30d7\\u30ed\\u30f3\\u30d7\\u30c8", schema
|
||||
print("SUCCESS")
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"load_schema failed under non-UTF-8 locale:\n{result.stderr}"
|
||||
)
|
||||
assert "SUCCESS" in result.stdout
|
||||
|
||||
|
||||
def test_load_schema_tolerates_utf8_bom(tmp_path):
|
||||
"""A schema saved by a Windows GUI editor (UTF-8 BOM) still parses.
|
||||
|
||||
json.load rejects a leading U+FEFF with JSONDecodeError, so a BOM-blind
|
||||
read turns "user edited the file in Notepad" into a hard failure.
|
||||
"""
|
||||
schema_path = tmp_path / "schema.json"
|
||||
schema_path.write_bytes(
|
||||
b"\xef\xbb\xbf" + json.dumps({"prompt": {"type": "string"}}).encode("utf-8")
|
||||
)
|
||||
|
||||
result = _run_under_c_locale(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, {str(SCRIPTS)!r})
|
||||
from run_workflow import load_schema
|
||||
|
||||
schema = load_schema({str(schema_path)!r}, {{}})
|
||||
assert schema["prompt"]["type"] == "string", schema
|
||||
print("SUCCESS")
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"load_schema rejected a BOM-prefixed schema:\n{result.stderr}"
|
||||
)
|
||||
assert "SUCCESS" in result.stdout
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the competitor-news-monitor skill and competitor-watch blueprint."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL_PATH = (
|
||||
REPO_ROOT / "skills" / "research" / "competitor-news-monitor" / "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "competitor-news-monitor"
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent")
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(REPO_ROOT.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(REPO_ROOT.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(REPO_ROOT.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_no_phantom_skill_references():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "change-monitor-and-notify" not in content, "phantom skill ref must be gone"
|
||||
|
||||
|
||||
def test_setup_tick_split():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "Setup (foreground, once)" in body
|
||||
assert "Tick (each scheduled run)" in body
|
||||
assert "cronjob(action=" in body, "must wire scheduling through the cronjob tool"
|
||||
|
||||
|
||||
def test_coverage_honesty_discipline():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "unknown coverage" in body, "source failure != no news"
|
||||
assert "cutoff advance" in body.replace("advances", "advance").replace(
|
||||
"advanced", "advance"
|
||||
), "cutoff must only advance on success"
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
|
||||
|
||||
def test_competitor_watch_blueprint_registered():
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
bp = next((b for b in CATALOG if b.key == "competitor-watch"), None)
|
||||
assert bp is not None, "competitor-watch blueprint missing from catalog"
|
||||
assert "competitor-news-monitor" in bp.skills
|
||||
slot_names = {s.name for s in bp.slots}
|
||||
assert {"companies", "categories", "time", "recurrence", "deliver"} <= slot_names
|
||||
assert "[SILENT]" in bp.prompt_template
|
||||
assert "{companies}" in bp.prompt_template and "{categories}" in bp.prompt_template
|
||||
|
||||
|
||||
def test_every_blueprint_skill_resolves_in_repo():
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
for bp in CATALOG:
|
||||
for skill_name in bp.skills:
|
||||
hits = list(REPO_ROOT.glob(f"skills/*/{skill_name}/SKILL.md")) + list(
|
||||
REPO_ROOT.glob(f"skills/*/*/{skill_name}/SKILL.md")
|
||||
)
|
||||
assert hits, f"blueprint {bp.key!r} loads nonexistent skill {skill_name!r}"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
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_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_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}"
|
||||
|
||||
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for optional-skills/productivity/decision-questionnaire."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SKILL_MD = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "productivity"
|
||||
/ "decision-questionnaire"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter():
|
||||
text = SKILL_MD.read_text(encoding="utf-8")
|
||||
m = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
|
||||
assert m, "SKILL.md missing YAML frontmatter"
|
||||
return yaml.safe_load(m.group(1))
|
||||
|
||||
|
||||
class TestFrontmatter:
|
||||
def test_name_matches_directory(self):
|
||||
assert _frontmatter()["name"] == "decision-questionnaire"
|
||||
|
||||
def test_description_length_and_period(self):
|
||||
desc = _frontmatter()["description"]
|
||||
assert len(desc) <= 60
|
||||
assert desc.endswith(".")
|
||||
|
||||
def test_license_and_platforms(self):
|
||||
fm = _frontmatter()
|
||||
assert fm["license"] == "MIT"
|
||||
assert set(fm["platforms"]) == {"linux", "macos", "windows"}
|
||||
|
||||
|
||||
class TestBody:
|
||||
def _body(self):
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
def test_template_sections_present(self):
|
||||
body = self._body()
|
||||
for section in ("## Context", "## How to answer", "## Anything else?"):
|
||||
assert section in body, f"template missing {section}"
|
||||
|
||||
def test_output_filename_convention(self):
|
||||
assert "decision-questionnaire-<slug>.md" in self._body()
|
||||
|
||||
def test_interview_the_send_principle(self):
|
||||
assert "Interview the Send" in self._body()
|
||||
|
||||
def test_no_upstream_harness_residue(self):
|
||||
lower = self._body().lower()
|
||||
for token in ("claude", "slash command", "disable-model-invocation"):
|
||||
assert token not in lower, f"upstream residue: {token}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the document-to-action-items optional skill."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "productivity"
|
||||
/ "document-to-action-items"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "document-to-action-items"
|
||||
hermes = fm["metadata"]["hermes"]
|
||||
assert hermes["tags"]
|
||||
assert "related_skills" in hermes
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent"), "human contributor must be credited first"
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = list(repo_root.glob(f"skills/*/{name}/SKILL.md")) + list(
|
||||
repo_root.glob(f"optional-skills/*/{name}/SKILL.md")
|
||||
) + list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_body_structure_and_size():
|
||||
_, body = _frontmatter_and_body()
|
||||
for section in ("## When to Use", "## Procedure", "## Pitfalls", "## Verification"):
|
||||
assert section in body, f"missing section: {section}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
assert not re.search(r"[A-Z]:\\\\Users", content)
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the email-inbox-triage bundled skill."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "email"
|
||||
/ "email-inbox-triage"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "email-inbox-triage"
|
||||
hermes = fm["metadata"]["hermes"]
|
||||
assert hermes["tags"]
|
||||
assert "related_skills" in hermes
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent"), "human contributor must be credited first"
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(repo_root.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_body_structure_and_size():
|
||||
_, body = _frontmatter_and_body()
|
||||
for section in ("## When to Use", "## Procedure", "## Pitfalls", "## Verification"):
|
||||
assert section in body, f"missing section: {section}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
assert not re.search(r"[A-Z]:\\\\Users", content)
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_mutation_boundary_is_default_safe():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "read + draft" in body, "scope step must default to read+draft, not send/delete"
|
||||
assert "does not imply permission" in body
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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_shorts_url(self):
|
||||
assert fetch_transcript.extract_video_id("https://www.youtube.com/shorts/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_zero(self):
|
||||
assert fetch_transcript.format_timestamp(0) == "0:00"
|
||||
|
||||
def test_minutes_only(self):
|
||||
assert fetch_transcript.format_timestamp(600) == "10:00"
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Regression tests for Tirith-safe GitHub credential extraction (#22722)."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = REPO_ROOT / "skills/software-development/github/scripts/git-credential-token.py"
|
||||
LEGACY_SED = r"sed 's|https://[^:]*:\([^@]*\)@.*|\1|'"
|
||||
SHIPPED_TREES = (
|
||||
REPO_ROOT / "skills/software-development/github",
|
||||
REPO_ROOT / "website/docs/user-guide/skills/bundled/software-development",
|
||||
REPO_ROOT
|
||||
/ "website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development",
|
||||
)
|
||||
|
||||
|
||||
def _extract(path: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(HELPER), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _credential_file(tmp_path: Path, value: str) -> Path:
|
||||
credentials = tmp_path / "credentials"
|
||||
credentials.write_text(value, encoding="utf-8", newline="")
|
||||
return credentials
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("credential", "token"),
|
||||
[
|
||||
("https://octocat:password-form-token@github.com\n", "password-form-token"),
|
||||
("https://oauth-token:x-oauth-basic@github.com\n", "oauth-token"),
|
||||
("https://ghp_token_only@github.com\n", "ghp_token_only"),
|
||||
("https://github_pat_token_only@github.com\n", "github_pat_token_only"),
|
||||
],
|
||||
)
|
||||
def test_extracts_supported_git_credential_url_forms(tmp_path, credential, token):
|
||||
result = _extract(_credential_file(tmp_path, credential))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == f"{token}\n"
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_extracts_password_from_exact_github_https_credential(tmp_path):
|
||||
credentials = _credential_file(
|
||||
tmp_path,
|
||||
"https://ignored:wrong@example.com\n"
|
||||
"https://octocat:secret%2Ftoken@github.com\n",
|
||||
)
|
||||
|
||||
result = _extract(credentials)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "secret/token\n"
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credential",
|
||||
[
|
||||
"https://octocat:stolen@github.com.attacker.example\n",
|
||||
"https://octocat@github.com\n",
|
||||
"https://%6fctocat@github.com\n",
|
||||
"https://octocat:token@github.com%2eattacker.example\n",
|
||||
"https://octocat:token%0D%0AX-Injected%3Ayes@github.com\n",
|
||||
"https://ghp_token%0Ainjected@github.com\n",
|
||||
"https://octocat:token%00suffix@github.com\n",
|
||||
"https://octocat:token%09suffix@github.com\n",
|
||||
"https://octocat:token%C2%85suffix@github.com\n",
|
||||
"https://ghp_token%1Fsuffix@github.com\n",
|
||||
"https://ghp_token%C2%9Fsuffix@github.com\n",
|
||||
"https://octocat:bad%ZZtoken@github.com\n",
|
||||
"https://octocat:token@github.com:bogus\n",
|
||||
"http://octocat:token@github.com\n",
|
||||
],
|
||||
)
|
||||
def test_rejects_ambiguous_lookalike_or_malformed_credentials(tmp_path, credential):
|
||||
result = _extract(_credential_file(tmp_path, credential))
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_bundled_github_skills_and_docs_do_not_ship_legacy_sed_url_regex():
|
||||
offenders = []
|
||||
for tree in SHIPPED_TREES:
|
||||
for path in tree.rglob("*"):
|
||||
if path.suffix in {".md", ".sh", ".py"} and LEGACY_SED in path.read_text(encoding="utf-8"):
|
||||
offenders.append(str(path.relative_to(REPO_ROOT)))
|
||||
|
||||
assert offenders == []
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for the github merged skill (formerly six github-* skills).
|
||||
|
||||
The issue-to-pr workflow (originally the github-issue-to-pr skill,
|
||||
author benbarclay) now lives complete in references/issue-to-pr.md of the
|
||||
merged software-development/github skill. Frontmatter contracts apply to
|
||||
the merged SKILL.md; the content pins that guarded the issue-to-pr
|
||||
disciplines now check the reference body.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "software-development"
|
||||
/ "github"
|
||||
)
|
||||
SKILL_PATH = SKILL_DIR / "SKILL.md"
|
||||
ISSUE_TO_PR_REF = SKILL_DIR / "references" / "issue-to-pr.md"
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_all_workflow_references_exist():
|
||||
for ref in (
|
||||
"auth.md",
|
||||
"issues.md",
|
||||
"pr-workflow.md",
|
||||
"issue-to-pr.md",
|
||||
"code-review.md",
|
||||
"repo-management.md",
|
||||
):
|
||||
assert (SKILL_DIR / "references" / ref).is_file(), f"missing reference: {ref}"
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "github"
|
||||
hermes = fm["metadata"]["hermes"]
|
||||
assert hermes["tags"]
|
||||
assert "related_skills" in hermes
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent"), "human contributor must be credited first"
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(repo_root.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_body_routes_every_workflow():
|
||||
_, body = _frontmatter_and_body()
|
||||
for ref in (
|
||||
"references/auth.md",
|
||||
"references/issues.md",
|
||||
"references/pr-workflow.md",
|
||||
"references/issue-to-pr.md",
|
||||
"references/code-review.md",
|
||||
"references/repo-management.md",
|
||||
):
|
||||
assert ref in body, f"routing table missing: {ref}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
for p in (SKILL_PATH, ISSUE_TO_PR_REF):
|
||||
content = p.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
assert not re.search(r"[A-Z]:\\\\Users", content)
|
||||
|
||||
|
||||
def test_issue_to_pr_steps_have_completion_criteria():
|
||||
body = ISSUE_TO_PR_REF.read_text(encoding="utf-8")
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## |\Z)", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 6
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_issue_to_pr_core_disciplines_present():
|
||||
"""The learnings folded in from maintainer practice must survive edits."""
|
||||
body = ISSUE_TO_PR_REF.read_text(encoding="utf-8")
|
||||
assert "--comments" in body, "must read the full issue thread"
|
||||
assert "pr list --search" in body, "must sweep for duplicate PRs before coding"
|
||||
assert re.search(r"git log -p -S", body), "must check design intent via history"
|
||||
assert "sabotage" in body.lower() or "FAILS" in body, "must prove the regression test bites"
|
||||
assert "sibling" in body, "must fix the class, not the site"
|
||||
assert "dispatches CI" in body, "must open the PR immediately after work exists"
|
||||
|
||||
|
||||
def test_issue_to_pr_not_a_router():
|
||||
"""Reference steps must carry their own procedure, not route elsewhere."""
|
||||
body = ISSUE_TO_PR_REF.read_text(encoding="utf-8")
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## |\Z)", body, re.MULTILINE | re.DOTALL)
|
||||
routing = [s for s in steps if re.match(r"^### \d+\.[^\n]*\n+Load `", s)]
|
||||
assert len(routing) == 0, "steps must not open by delegating to another skill"
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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_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_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,73 @@
|
||||
"""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()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the google-workspace daily-brief reference."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
GWS_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "productivity"
|
||||
/ "google-workspace"
|
||||
)
|
||||
REF_PATH = GWS_DIR / "references" / "daily-brief.md"
|
||||
SKILL_PATH = GWS_DIR / "SKILL.md"
|
||||
|
||||
|
||||
def test_reference_exists():
|
||||
assert REF_PATH.is_file()
|
||||
|
||||
|
||||
def test_skill_md_points_at_reference():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "references/daily-brief.md" in content, "SKILL.md must list the reference"
|
||||
refs_section = content.split("## References", 1)[1].split("##", 1)[0]
|
||||
assert "daily-brief" in refs_section
|
||||
|
||||
|
||||
def test_reference_carries_load_trigger():
|
||||
content = REF_PATH.read_text(encoding="utf-8")
|
||||
assert "morning brief" in content.lower(), "reference must state when to load it"
|
||||
|
||||
|
||||
def test_reference_credits_contributor():
|
||||
content = REF_PATH.read_text(encoding="utf-8")
|
||||
assert "benbarclay" in content
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
content = REF_PATH.read_text(encoding="utf-8")
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", content, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_core_disciplines_present():
|
||||
content = REF_PATH.read_text(encoding="utf-8")
|
||||
assert "half-open window" in content or "[day_start, next_day_start)" in content
|
||||
assert "no preparation found" in content
|
||||
assert "not authorization to mutate" in content
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = REF_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
|
||||
|
||||
def test_no_standalone_daily_brief_skill():
|
||||
"""The brief ships as a reference, not a sibling skill."""
|
||||
repo_root = GWS_DIR.parents[2]
|
||||
assert not (repo_root / "skills" / "productivity" / "google-workspace-daily-brief").exists()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Security-floor tests for the Google Workspace runtime installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SETUP_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/setup.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def setup_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"test_google_workspace_setup_module",
|
||||
SETUP_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_stale_google_transitives_are_reported_missing(setup_module, monkeypatch):
|
||||
installed = {
|
||||
"google-api-python-client": "2.194.0",
|
||||
"google-auth": "2.55.0",
|
||||
"google-auth-oauthlib": "1.3.1",
|
||||
"google-auth-httplib2": "0.3.1",
|
||||
"httplib2": "0.31.2",
|
||||
"pyasn1": "0.6.3",
|
||||
}
|
||||
|
||||
def fake_version(name):
|
||||
try:
|
||||
return installed[name]
|
||||
except KeyError:
|
||||
raise PackageNotFoundError(name) from None
|
||||
|
||||
monkeypatch.setattr(setup_module, "_distribution_version", fake_version)
|
||||
|
||||
assert setup_module._missing_required_packages() == [
|
||||
"google-auth==2.55.1",
|
||||
"httplib2==0.32.0",
|
||||
"pyasn1==0.6.4",
|
||||
]
|
||||
|
||||
|
||||
def test_installer_repairs_stale_transitives(setup_module, monkeypatch):
|
||||
states = iter(
|
||||
[
|
||||
[
|
||||
"google-auth==2.55.1",
|
||||
"httplib2==0.32.0",
|
||||
"pyasn1==0.6.4",
|
||||
],
|
||||
[],
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
setup_module,
|
||||
"_missing_required_packages",
|
||||
lambda: next(states),
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
setup_module.subprocess,
|
||||
"check_call",
|
||||
lambda argv, **kwargs: calls.append(argv),
|
||||
)
|
||||
|
||||
assert setup_module.install_deps() is True
|
||||
assert calls == [
|
||||
[
|
||||
setup_module.sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"google-auth==2.55.1",
|
||||
"httplib2==0.32.0",
|
||||
"pyasn1==0.6.4",
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Regression test: google-workspace setup.py REQUIRED_PACKAGES must pin httplib2.
|
||||
|
||||
GHSA-j5g9-f88f-gfj3 (HIGH) — Decompression Bomb DoS via unbounded gzip/deflate
|
||||
response handling. Fixed in httplib2 0.32.0.
|
||||
|
||||
There are three install paths for google-workspace dependencies:
|
||||
1. pyproject.toml [project.optional-dependencies].google
|
||||
2. tools/lazy_deps.py LAZY_DEPS['skill.google_workspace']
|
||||
3. skills/productivity/google-workspace/scripts/setup.py REQUIRED_PACKAGES
|
||||
|
||||
This test ensures path 3 stays pinned and consistent with the other two.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
SETUP_PY = REPO_ROOT / "skills/productivity/google-workspace/scripts/setup.py"
|
||||
PYPROJECT_TOML = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GOOGLE_EXTRA_KEY = "google"
|
||||
_LAZY_DEPS_KEY = "skill.google_workspace"
|
||||
|
||||
|
||||
def _parse_setup_py_required_packages() -> list[str]:
|
||||
"""Parse setup.py and return the REQUIRED_PACKAGES list."""
|
||||
tree = ast.parse(SETUP_PY.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "REQUIRED_PACKAGES":
|
||||
if isinstance(node.value, ast.List):
|
||||
return [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant)]
|
||||
raise AssertionError("REQUIRED_PACKAGES not found in setup.py")
|
||||
|
||||
|
||||
def _parse_pyproject_google_extra() -> list[str]:
|
||||
"""Parse pyproject.toml and return the google extra dependency list."""
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
data = tomllib.loads(PYPROJECT_TOML.read_text(encoding="utf-8"))
|
||||
optional_deps = data["project"]["optional-dependencies"]
|
||||
return list(optional_deps[_GOOGLE_EXTRA_KEY])
|
||||
|
||||
|
||||
def _parse_lazy_deps_google_workspace() -> list[str]:
|
||||
"""Return the real LAZY_DEPS entry for skill.google_workspace."""
|
||||
from tools.lazy_deps import LAZY_DEPS
|
||||
|
||||
return list(LAZY_DEPS[_LAZY_DEPS_KEY])
|
||||
|
||||
|
||||
def _extract_pins(packages: list[str]) -> dict[str, str]:
|
||||
"""Extract pinned versions: {package_name: version} for entries with == pin."""
|
||||
pins: dict[str, str] = {}
|
||||
for pkg in packages:
|
||||
if "==" in pkg:
|
||||
name, version = pkg.split("==", 1)
|
||||
pins[name.strip()] = version.strip()
|
||||
return pins
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGoogleWorkspaceSetupDepsPins:
|
||||
"""Security pin consistency across all three google-workspace install paths."""
|
||||
|
||||
def test_setup_py_pins_httplib2(self):
|
||||
"""setup.py REQUIRED_PACKAGES must pin httplib2 at or above the GHSA fix version."""
|
||||
packages = _parse_setup_py_required_packages()
|
||||
pins = _extract_pins(packages)
|
||||
assert "httplib2" in pins, (
|
||||
f"httplib2 not found in setup.py REQUIRED_PACKAGES.\n"
|
||||
f" Current entries: {packages}"
|
||||
)
|
||||
# GHSA-j5g9-f88f-gfj3 is fixed in 0.32.0 — floor invariant, not a snapshot,
|
||||
# so future bumps don't break this test.
|
||||
pinned = tuple(int(part) for part in pins["httplib2"].split("."))
|
||||
assert pinned >= (0, 32, 0), (
|
||||
f"httplib2 pin {pins['httplib2']} in setup.py is below 0.32.0, the "
|
||||
f"GHSA-j5g9-f88f-gfj3 fix version.\n"
|
||||
f" Full REQUIRED_PACKAGES: {packages}"
|
||||
)
|
||||
|
||||
def test_setup_py_pins_match_pyproject_toml(self):
|
||||
"""httplib2 pin in setup.py must match pyproject.toml google extra."""
|
||||
required_packages = _parse_setup_py_required_packages()
|
||||
pyproject_packages = _parse_pyproject_google_extra()
|
||||
|
||||
required_pins = _extract_pins(required_packages)
|
||||
pyproject_pins = _extract_pins(pyproject_packages)
|
||||
|
||||
for pkg in ("httplib2", "google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"):
|
||||
setup_ver = required_pins.get(pkg)
|
||||
toml_ver = pyproject_pins.get(pkg)
|
||||
if setup_ver is None and toml_ver is None:
|
||||
continue # neither path pins it, skip
|
||||
assert toml_ver is not None, (
|
||||
f"{pkg} is pinned in setup.py ({setup_ver}) but NOT in pyproject.toml google extra.\n"
|
||||
f" setup.py: {required_pins}\n"
|
||||
f" pyproject.toml google: {pyproject_pins}"
|
||||
)
|
||||
assert setup_ver is not None, (
|
||||
f"{pkg} is pinned in pyproject.toml ({toml_ver}) but NOT in setup.py.\n"
|
||||
f" pyproject.toml google: {pyproject_pins}\n"
|
||||
f" setup.py: {required_pins}"
|
||||
)
|
||||
assert setup_ver == toml_ver, (
|
||||
f"{pkg} pin mismatch: setup.py has {setup_ver}, pyproject.toml has {toml_ver}.\n"
|
||||
f" setup.py: {required_pins}\n"
|
||||
f" pyproject.toml google: {pyproject_pins}"
|
||||
)
|
||||
|
||||
def test_setup_py_pins_match_lazy_deps(self):
|
||||
"""httplib2 pin in setup.py must match tools/lazy_deps.py skill.google_workspace."""
|
||||
required_packages = _parse_setup_py_required_packages()
|
||||
lazy_packages = _parse_lazy_deps_google_workspace()
|
||||
|
||||
required_pins = _extract_pins(required_packages)
|
||||
lazy_pins = _extract_pins(lazy_packages)
|
||||
|
||||
for pkg in ("httplib2", "google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"):
|
||||
setup_ver = required_pins.get(pkg)
|
||||
lazy_ver = lazy_pins.get(pkg)
|
||||
if setup_ver is None and lazy_ver is None:
|
||||
continue
|
||||
assert lazy_ver is not None, (
|
||||
f"{pkg} is pinned in setup.py ({setup_ver}) but NOT in lazy_deps.py.\n"
|
||||
f" setup.py: {required_pins}\n"
|
||||
f" lazy_deps.py: {lazy_pins}"
|
||||
)
|
||||
assert setup_ver is not None, (
|
||||
f"{pkg} is pinned in lazy_deps.py ({lazy_ver}) but NOT in setup.py.\n"
|
||||
f" lazy_deps.py: {lazy_pins}\n"
|
||||
f" setup.py: {required_pins}"
|
||||
)
|
||||
assert setup_ver == lazy_ver, (
|
||||
f"{pkg} pin mismatch: setup.py has {setup_ver}, lazy_deps.py has {lazy_ver}.\n"
|
||||
f" setup.py: {required_pins}\n"
|
||||
f" lazy_deps.py: {lazy_pins}"
|
||||
)
|
||||
|
||||
def test_all_google_packages_are_pinned_in_all_paths(self):
|
||||
"""Every google workspace package that is version-pinned in any path must appear in all three."""
|
||||
pyproject_packages = _parse_pyproject_google_extra()
|
||||
lazy_packages = _parse_lazy_deps_google_workspace()
|
||||
setup_packages = _parse_setup_py_required_packages()
|
||||
|
||||
all_pins: dict[str, set[str]] = {}
|
||||
for label, pkgs in [
|
||||
("pyproject.toml", pyproject_packages),
|
||||
("lazy_deps.py", lazy_packages),
|
||||
("setup.py", setup_packages),
|
||||
]:
|
||||
for pkg in pkgs:
|
||||
if "==" in pkg:
|
||||
name, ver = pkg.split("==", 1)
|
||||
all_pins.setdefault(name.strip(), set()).add(f"{label}={ver.strip()}")
|
||||
|
||||
for pkg, entries in sorted(all_pins.items()):
|
||||
versions = {e.split("=", 1)[1] for e in entries}
|
||||
assert len(versions) == 1, (
|
||||
f"{pkg} has inconsistent pins across install paths:\n"
|
||||
+ "\n".join(f" {e}" for e in sorted(entries))
|
||||
)
|
||||
assert len(entries) == 3, (
|
||||
f"{pkg} is not pinned in all three install paths. Found {len(entries)}/3:\n"
|
||||
+ "\n".join(f" {e}" for e in sorted(entries))
|
||||
)
|
||||
@@ -0,0 +1,606 @@
|
||||
"""Tests for the grounded-citations bundled skill.
|
||||
|
||||
Covers the SKILL.md authoring standards (frontmatter shape, ≤60-char
|
||||
description) and the behavior of ``scripts/sources.py`` — the citation ledger
|
||||
that assigns stable ``url -> [n]`` ids, renders Sources blocks, and verifies a
|
||||
draft's citations. The verify path is the load-bearing piece: it is what
|
||||
catches a hallucinated or renumbered citation before delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[2] / "skills" / "research" / "grounded-citations"
|
||||
SCRIPT = SKILL_DIR / "scripts" / "sources.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frontmatter() -> dict:
|
||||
src = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
m = re.search(r"^---\n(.*?)\n---", src, re.DOTALL)
|
||||
assert m, "SKILL.md missing YAML frontmatter"
|
||||
return yaml.safe_load(m.group(1))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sources_mod():
|
||||
spec = importlib.util.spec_from_file_location("gc_sources", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ledger(tmp_path: Path) -> Path:
|
||||
return tmp_path / "ledger.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authoring standards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skill_files_present() -> None:
|
||||
assert (SKILL_DIR / "SKILL.md").is_file()
|
||||
assert SCRIPT.is_file()
|
||||
assert (SKILL_DIR / "references" / "citation-formats.md").is_file()
|
||||
assert (SKILL_DIR / "references" / "grounding-rationale.md").is_file()
|
||||
|
||||
|
||||
def test_description_within_limit(frontmatter: dict) -> None:
|
||||
desc = frontmatter["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars (limit 60): {desc!r}"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_required_frontmatter_fields(frontmatter: dict) -> None:
|
||||
assert frontmatter["name"] == "grounded-citations"
|
||||
for field in ("version", "author", "license", "platforms"):
|
||||
assert frontmatter.get(field), f"missing frontmatter field: {field}"
|
||||
assert frontmatter["metadata"]["hermes"]["category"] == "research"
|
||||
|
||||
|
||||
def test_skill_body_has_modern_sections() -> None:
|
||||
body = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
for heading in (
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
):
|
||||
assert heading in body, f"SKILL.md missing section: {heading}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ledger identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ids_are_stable_and_sequential(sources_mod, ledger: Path) -> None:
|
||||
first = sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
second = sources_mod.add_sources(ledger, ["https://b.example"])
|
||||
assert (first[0]["id"], second[0]["id"]) == (1, 2)
|
||||
again = sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
assert again[0]["id"] == 1
|
||||
|
||||
|
||||
def test_url_normalization_collapses_fragment_and_trailing_slash(sources_mod, ledger: Path) -> None:
|
||||
base = sources_mod.add_sources(ledger, ["https://x.example/page"])[0]["id"]
|
||||
for variant in ("https://x.example/page/", "https://x.example/page#part"):
|
||||
assert sources_mod.add_sources(ledger, [variant])[0]["id"] == base
|
||||
|
||||
|
||||
def test_query_string_is_significant(sources_mod, ledger: Path) -> None:
|
||||
a = sources_mod.add_sources(ledger, ["https://x.example/s?q=1"])[0]["id"]
|
||||
b = sources_mod.add_sources(ledger, ["https://x.example/s?q=2"])[0]["id"]
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_title_backfills_without_changing_id(sources_mod, ledger: Path) -> None:
|
||||
first = sources_mod.add_sources(ledger, ["https://t.example"])[0]
|
||||
assert first["title"] == ""
|
||||
second = sources_mod.add_sources(ledger, ["https://t.example"], title="Later title")[0]
|
||||
assert (second["id"], second["title"]) == (first["id"], "Later title")
|
||||
|
||||
|
||||
def test_ingest_walks_search_and_extract_payloads(sources_mod) -> None:
|
||||
payload = {
|
||||
"data": {"web": [{"title": "One", "url": "https://n.example/1"}]},
|
||||
"results": [
|
||||
{"url": "https://n.example/1", "title": "One again"},
|
||||
{"url": "https://n.example/2", "title": "Two"},
|
||||
],
|
||||
}
|
||||
pairs = sources_mod.urls_from_json(payload)
|
||||
assert [u for u, _ in pairs] == ["https://n.example/1", "https://n.example/2"]
|
||||
|
||||
|
||||
def test_ingest_ignores_non_http_values(sources_mod) -> None:
|
||||
payload = {"url": "file:///etc/passwd", "nested": {"link": "mailto:a@b.c"}}
|
||||
assert sources_mod.urls_from_json(payload) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed(sources_mod, ledger: Path) -> list[dict]:
|
||||
sources_mod.add_sources(ledger, ["https://a.example"], title="Alpha")
|
||||
sources_mod.add_sources(ledger, ["https://b.example"])
|
||||
sources_mod.add_sources(ledger, ["https://c.example"], title="Gamma")
|
||||
return json.loads(ledger.read_text(encoding="utf-8"))["sources"]
|
||||
|
||||
|
||||
def test_render_markdown_lists_ids_and_urls(sources_mod, ledger: Path) -> None:
|
||||
block = sources_mod.render_sources(_seed(sources_mod, ledger))
|
||||
assert block.startswith("## Sources")
|
||||
assert "[1] https://a.example — Alpha" in block
|
||||
assert "[2] https://b.example" in block
|
||||
|
||||
|
||||
def test_render_only_subset_and_ordering(sources_mod, ledger: Path) -> None:
|
||||
block = sources_mod.render_sources(_seed(sources_mod, ledger), style="plain", only={3, 1})
|
||||
lines = [ln for ln in block.splitlines() if ln.startswith("[")]
|
||||
assert lines[0].startswith("[1]") and lines[1].startswith("[3]")
|
||||
assert not any(ln.startswith("[2]") for ln in lines)
|
||||
|
||||
|
||||
def test_render_bibtex_keys_match_ids(sources_mod, ledger: Path) -> None:
|
||||
block = sources_mod.render_sources(_seed(sources_mod, ledger), style="bibtex", only={1})
|
||||
assert "@misc{source1," in block
|
||||
assert r"\url{https://a.example}" in block
|
||||
|
||||
|
||||
def test_render_empty_selection_is_empty_string(sources_mod, ledger: Path) -> None:
|
||||
assert sources_mod.render_sources(_seed(sources_mod, ledger), only=set()) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification — the guarantee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify(sources_mod, ledger: Path, tmp_path: Path, text: str, **kw):
|
||||
draft = tmp_path / "draft.md"
|
||||
draft.write_text(text, encoding="utf-8")
|
||||
sources = json.loads(ledger.read_text(encoding="utf-8"))["sources"]
|
||||
return sources_mod.verify_draft(draft, sources, **kw)
|
||||
|
||||
|
||||
def test_well_formed_draft_passes(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"Ice is less dense than liquid water and floats.[1][2]\n\n"
|
||||
"Sources:\n[1] https://a.example\n[2] https://b.example\n"
|
||||
)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_unknown_citation_id_fails(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = "A claim with an invented source id here.[42]\n\nSources:\n[42] https://fake.example\n"
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert code == 1
|
||||
# Must be flagged as an inline citation the ledger never issued — not merely
|
||||
# as a stray Sources-block line, which is a separate (weaker) error.
|
||||
assert any("hallucinated or renumbered" in e for e in errors), errors
|
||||
|
||||
|
||||
def test_sources_block_url_must_match_ledger(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = "A real claim carrying a real id.[1]\n\nSources:\n[1] https://wrong.example\n"
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert code == 1
|
||||
assert any("does not match the ledger" in e for e in errors)
|
||||
|
||||
|
||||
def test_missing_sources_block_fails(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, "A real claim carrying an id.[1]\n")
|
||||
assert code == 1
|
||||
assert any("no `Sources:` block" in e for e in errors)
|
||||
|
||||
|
||||
def test_cited_but_absent_from_block_fails(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"First claim about the topic at hand.[1]\n"
|
||||
"Second claim about the topic at hand.[2]\n\n"
|
||||
"Sources:\n[1] https://a.example\n"
|
||||
)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert code == 1
|
||||
assert any("absent from the Sources block" in e for e in errors)
|
||||
|
||||
|
||||
def test_brackets_inside_code_fences_are_not_citations(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = "Prose with no external claims in it.\n\n```python\nvalue = arr[42]\n```\n"
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_markdown_links_are_not_citations(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = "See the [docs](https://x.example) for the full option list.\n"
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_min_coverage_gate(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"A cited claim about the subject matter.[1]\n"
|
||||
"An uncited claim about the subject matter.\n"
|
||||
"Another uncited claim about the subject matter.\n"
|
||||
"A third uncited claim about the subject matter.\n\n"
|
||||
"Sources:\n[1] https://a.example\n"
|
||||
)
|
||||
ok_code, _, _ = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert ok_code == 0
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.5)
|
||||
assert code == 1
|
||||
assert any("coverage" in e for e in errors)
|
||||
|
||||
|
||||
def test_over_citation_warns_without_failing(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
sources_mod.add_sources(
|
||||
ledger, [f"https://s{i}.example" for i in range(1, 5)]
|
||||
)
|
||||
text = (
|
||||
"One sentence leaning on far too many sources at once.[1][2][3][4]\n\n"
|
||||
"Sources:\n"
|
||||
+ "".join(f"[{i}] https://s{i}.example\n" for i in range(1, 5))
|
||||
)
|
||||
code, errors, warnings = _verify(sources_mod, ledger, tmp_path, text)
|
||||
assert (code, errors) == (0, [])
|
||||
assert any("more than 3 citations" in w for w in warnings)
|
||||
|
||||
|
||||
def test_strict_mode_promotes_warnings_to_failure(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = "A cited claim about the subject.[1]\n\nSources:\n[1] https://a.example\n"
|
||||
assert _verify(sources_mod, ledger, tmp_path, text)[0] == 0
|
||||
assert _verify(sources_mod, ledger, tmp_path, text, strict=True)[0] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_add_render_verify_roundtrip(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "cli.json"
|
||||
args = ["--ledger", str(ledger)]
|
||||
assert sources_mod.main(args + ["add", "https://a.example", "https://b.example"]) == 0
|
||||
assert "[1] https://a.example" in capsys.readouterr().out
|
||||
|
||||
draft = tmp_path / "d.md"
|
||||
draft.write_text("Only the first source is used here.[1]\n", encoding="utf-8")
|
||||
assert sources_mod.main(args + ["render", "--cited-in", str(draft)]) == 0
|
||||
block = capsys.readouterr().out
|
||||
assert "[1] https://a.example" in block and "[2]" not in block
|
||||
|
||||
with draft.open("a", encoding="utf-8") as fh:
|
||||
fh.write("\n" + block)
|
||||
assert sources_mod.main(args + ["verify", str(draft)]) == 0
|
||||
|
||||
|
||||
def test_cli_reset_empties_the_ledger(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "r.json"
|
||||
args = ["--ledger", str(ledger)]
|
||||
sources_mod.main(args + ["add", "https://a.example"])
|
||||
capsys.readouterr()
|
||||
assert sources_mod.main(args + ["reset"]) == 0
|
||||
capsys.readouterr()
|
||||
assert sources_mod.main(args + ["add", "https://z.example"]) == 0
|
||||
assert "[1] https://z.example" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cli_verify_missing_draft_returns_2(sources_mod, tmp_path: Path) -> None:
|
||||
ledger = tmp_path / "m.json"
|
||||
code = sources_mod.main(["--ledger", str(ledger), "verify", str(tmp_path / "nope.md")])
|
||||
assert code == 2
|
||||
|
||||
|
||||
def test_cli_ledger_path_prefers_flag_over_env(sources_mod, tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("HERMES_CITATION_LEDGER", str(tmp_path / "env.json"))
|
||||
flagged = tmp_path / "flag.json"
|
||||
assert sources_mod.resolve_ledger_path(str(flagged)) == flagged
|
||||
assert sources_mod.resolve_ledger_path(None) == tmp_path / "env.json"
|
||||
|
||||
|
||||
def test_cli_ledger_path_defaults_under_hermes_home(sources_mod, tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("HERMES_CITATION_LEDGER", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
path = sources_mod.resolve_ledger_path(None)
|
||||
assert path.parts[-3:] == ("cache", "citations", "ledger.json")
|
||||
assert str(tmp_path) in str(path)
|
||||
|
||||
|
||||
def test_corrupt_ledger_raises_actionable_error(sources_mod, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text("not json", encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
sources_mod.load_ledger(bad)
|
||||
assert "reset" in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fact-checking: evidence quotes and [unverified] markers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PAGE = (
|
||||
"Water expands when it freezes.\n"
|
||||
"Ice is about 9% less dense than liquid water,\n"
|
||||
"which is why icebergs float.\n"
|
||||
)
|
||||
|
||||
|
||||
def test_quote_verbatim_match_is_whitespace_and_case_insensitive(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
entry = sources_mod.attach_quote(
|
||||
ledger, 1, "ice is about 9% less dense than liquid water,", _PAGE
|
||||
)
|
||||
assert len(entry["quotes"]) == 1
|
||||
|
||||
|
||||
def test_quote_rejects_paraphrase(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
sources_mod.attach_quote(ledger, 1, "Frozen water is roughly 9% lighter", _PAGE)
|
||||
assert "not found verbatim" in str(exc.value)
|
||||
|
||||
|
||||
def test_quote_rejects_unknown_id_and_short_text(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
sources_mod.attach_quote(ledger, 7, "which is why icebergs float.", _PAGE)
|
||||
assert "no source [7]" in str(exc.value)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
sources_mod.attach_quote(ledger, 1, "icebergs float.", _PAGE)
|
||||
assert "too short" in str(exc.value)
|
||||
|
||||
|
||||
def test_quote_is_idempotent(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://a.example"])
|
||||
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
|
||||
entry = sources_mod.attach_quote(ledger, 1, "water expands when it freezes.", _PAGE)
|
||||
assert len(entry["quotes"]) == 1
|
||||
|
||||
|
||||
def test_verify_evidence_gate_requires_quotes(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"A claim supported by the first source.[1]\n\n"
|
||||
"Sources:\n[1] https://a.example\n"
|
||||
)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
|
||||
assert code == 1
|
||||
assert any("no verbatim evidence quote" in e for e in errors)
|
||||
|
||||
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_evidence_gate_only_applies_to_cited_sources(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
|
||||
# [2] and [3] have no quotes but are not cited — the gate must not fail on them.
|
||||
text = "A claim supported by the first source.[1]\n\nSources:\n[1] https://a.example\n"
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_unverified_marker_counts_toward_coverage(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"A cited claim about the subject matter.[1]\n"
|
||||
"A model-knowledge claim declared as such.[unverified]\n\n"
|
||||
"Sources:\n[1] https://a.example\n"
|
||||
)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9)
|
||||
assert (code, errors) == (0, [])
|
||||
|
||||
|
||||
def test_unverified_marker_does_not_hide_uncited_sentences(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"A cited claim about the subject matter.[1]\n"
|
||||
"An uncited, unmarked claim about the subject.\n"
|
||||
"Another uncited, unmarked claim about the subject.\n\n"
|
||||
"Sources:\n[1] https://a.example\n"
|
||||
)
|
||||
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9)
|
||||
assert code == 1
|
||||
assert any("coverage" in e for e in errors)
|
||||
|
||||
|
||||
def test_render_evidence_style_includes_quotes(sources_mod, ledger: Path) -> None:
|
||||
_seed(sources_mod, ledger)
|
||||
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
|
||||
sources = json.loads(ledger.read_text(encoding="utf-8"))["sources"]
|
||||
block = sources_mod.render_sources(sources, style="evidence", only={1})
|
||||
assert "[1] https://a.example" in block
|
||||
assert '> "Water expands when it freezes."' in block
|
||||
plain = sources_mod.render_sources(sources, style="markdown", only={1})
|
||||
assert "Water expands" not in plain
|
||||
|
||||
|
||||
def test_cli_quote_and_evidence_verify_roundtrip(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "ev.json"
|
||||
page = tmp_path / "page.txt"
|
||||
page.write_text(_PAGE, encoding="utf-8")
|
||||
args = ["--ledger", str(ledger)]
|
||||
assert sources_mod.main(args + ["add", "https://a.example"]) == 0
|
||||
capsys.readouterr()
|
||||
|
||||
assert (
|
||||
sources_mod.main(
|
||||
args + ["quote", "1", "--text", "Water expands when it freezes.", "--from", str(page)]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert "evidence attached" in capsys.readouterr().out
|
||||
|
||||
draft = tmp_path / "d.md"
|
||||
draft.write_text(
|
||||
"A claim resting on the source page.[1]\n\nSources:\n[1] https://a.example\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert sources_mod.main(args + ["verify", str(draft), "--evidence"]) == 0
|
||||
capsys.readouterr()
|
||||
assert sources_mod.main(args + ["render", "--style", "evidence"]) == 0
|
||||
assert '> "Water expands when it freezes."' in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown-markup tolerance in the verbatim check
|
||||
#
|
||||
# Retrieval returns markdown, so the most citation-worthy sentences are the
|
||||
# ones carrying inline links and emphasis around terms. Requiring the agent to
|
||||
# reproduce that markup pushed a real live run toward a weaker evidence
|
||||
# fragment, which is the opposite of the skill's purpose.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Verbatim shape of the MedlinePlus sentence as web_extract returns it.
|
||||
_MD_PAGE = (
|
||||
"Variations in several additional genes, including "
|
||||
"_[ERAP1](https://medlineplus.gov/genetics/gene/erap1/)_, "
|
||||
"_[IL1A](https://medlineplus.gov/genetics/gene/il1a/)_, and "
|
||||
"_[IL23R](https://medlineplus.gov/genetics/gene/il23r/)_, have also been\n"
|
||||
"associated with ankylosing spondylitis.\n"
|
||||
"While over 90% of AS patients have an HLA-B\\*27 haplotype, only around 5% develop AS.\n"
|
||||
)
|
||||
|
||||
|
||||
def test_quote_matches_through_inline_links_and_emphasis(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://medlineplus.gov/x"])
|
||||
entry = sources_mod.attach_quote(
|
||||
ledger,
|
||||
1,
|
||||
"Variations in several additional genes, including ERAP1, IL1A, and IL23R, "
|
||||
"have also been associated with ankylosing spondylitis.",
|
||||
_MD_PAGE,
|
||||
)
|
||||
assert len(entry["quotes"]) == 1
|
||||
# The stored quote keeps the caller's clean prose — no extractor artifacts
|
||||
# leak into the rendered deliverable.
|
||||
assert "medlineplus.gov/genetics/gene" not in entry["quotes"][0]["text"]
|
||||
|
||||
|
||||
def test_quote_matches_through_escaped_asterisks(sources_mod, ledger: Path) -> None:
|
||||
sources_mod.add_sources(ledger, ["https://frontiersin.org/x"])
|
||||
entry = sources_mod.attach_quote(
|
||||
ledger, 1, "over 90% of AS patients have an HLA-B*27 haplotype", _MD_PAGE
|
||||
)
|
||||
assert entry["quotes"][0]["text"] == "over 90% of AS patients have an HLA-B*27 haplotype"
|
||||
|
||||
|
||||
def test_markup_tolerance_does_not_admit_paraphrase(sources_mod, ledger: Path) -> None:
|
||||
"""Seeing through markup must not weaken the substantive check."""
|
||||
sources_mod.add_sources(ledger, ["https://medlineplus.gov/x"])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
sources_mod.attach_quote(
|
||||
ledger, 1, "Several other immune genes are also linked to the disease.", _MD_PAGE
|
||||
)
|
||||
assert "not found verbatim" in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render --replace-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_replace_in_rewrites_block_idempotently(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "rp.json"
|
||||
page = tmp_path / "p.txt"
|
||||
page.write_text(_PAGE, encoding="utf-8")
|
||||
args = ["--ledger", str(ledger)]
|
||||
sources_mod.main(args + ["add", "https://a.example", "https://b.example"])
|
||||
sources_mod.main(
|
||||
args + ["quote", "1", "--text", "Water expands when it freezes.", "--from", str(page)]
|
||||
)
|
||||
capsys.readouterr()
|
||||
|
||||
draft = tmp_path / "d.md"
|
||||
draft.write_text(
|
||||
"A claim resting on the first source.[1]\n\n## Sources\n\n[1] https://stale.example\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert sources_mod.main(args + ["render", "--style", "evidence", "--replace-in", str(draft)]) == 0
|
||||
first = draft.read_text(encoding="utf-8")
|
||||
assert "stale.example" not in first
|
||||
assert first.count("## Sources") == 1
|
||||
assert '> "Water expands when it freezes."' in first
|
||||
# [2] is registered but uncited — --replace-in filters to cited ids.
|
||||
assert "b.example" not in first
|
||||
|
||||
assert sources_mod.main(args + ["render", "--style", "evidence", "--replace-in", str(draft)]) == 0
|
||||
assert draft.read_text(encoding="utf-8") == first, "second run must be a no-op"
|
||||
capsys.readouterr()
|
||||
assert sources_mod.main(args + ["verify", str(draft), "--evidence"]) == 0
|
||||
|
||||
|
||||
def test_render_replace_in_appends_when_no_block_exists(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "ap.json"
|
||||
args = ["--ledger", str(ledger)]
|
||||
sources_mod.main(args + ["add", "https://a.example"])
|
||||
draft = tmp_path / "d.md"
|
||||
draft.write_text("A claim resting on the first source.[1]\n", encoding="utf-8")
|
||||
assert sources_mod.main(args + ["render", "--replace-in", str(draft)]) == 0
|
||||
body = draft.read_text(encoding="utf-8")
|
||||
assert body.startswith("A claim resting on the first source.[1]")
|
||||
assert "## Sources" in body and "[1] https://a.example" in body
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output legibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stats_line_is_info_not_warn_on_success(sources_mod, tmp_path: Path, capsys) -> None:
|
||||
ledger = tmp_path / "st.json"
|
||||
args = ["--ledger", str(ledger)]
|
||||
sources_mod.main(args + ["add", "https://a.example"])
|
||||
draft = tmp_path / "d.md"
|
||||
draft.write_text(
|
||||
"A claim resting on the first source.[1]\n\nSources:\n[1] https://a.example\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capsys.readouterr()
|
||||
assert sources_mod.main(args + ["verify", str(draft)]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "info: stats:" in out
|
||||
assert "warn: stats:" not in out
|
||||
|
||||
|
||||
def test_stats_reports_provenance_total_matching_coverage(sources_mod, ledger: Path, tmp_path: Path) -> None:
|
||||
"""The stats line's counts must reconcile with the percentage it prints."""
|
||||
_seed(sources_mod, ledger)
|
||||
text = (
|
||||
"A cited claim about the subject matter.[1]\n"
|
||||
"A claim both cited and hedged as uncertain.[2][unverified]\n"
|
||||
"A model-knowledge claim declared as such.[unverified]\n"
|
||||
"An uncited, unmarked claim about the subject.\n\n"
|
||||
"Sources:\n[1] https://a.example\n[2] https://b.example\n"
|
||||
)
|
||||
_code, _errors, warnings = _verify(sources_mod, ledger, tmp_path, text)
|
||||
stats = warnings[0]
|
||||
# 4 sentences, 3 with provenance (the both-marked sentence counts once).
|
||||
assert "4 prose sentence(s), 3 with declared provenance (75%)" in stats
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for the har-derived-api-client optional skill.
|
||||
|
||||
Two layers, both stdlib + pytest, no network:
|
||||
1. Structural / frontmatter contract on SKILL.md (matches the maintainer
|
||||
review checklist for optional skills).
|
||||
2. Behavioral: run the real har_to_client.py logic against a synthetic HAR
|
||||
fixture and assert it derives the endpoint, collapses id path segments,
|
||||
filters static assets, and surfaces the User-Agent replay hint.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "web-development"
|
||||
/ "har-derived-api-client"
|
||||
)
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
CAPTURE = SKILL_DIR / "scripts" / "har_capture.py"
|
||||
CAPTURE_CDP = SKILL_DIR / "scripts" / "har_capture_cdp.py"
|
||||
DERIVE = SKILL_DIR / "scripts" / "har_to_client.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _load_module(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# --- structural contract ---------------------------------------------------
|
||||
|
||||
|
||||
def test_skill_files_exist():
|
||||
assert SKILL_MD.is_file()
|
||||
assert CAPTURE.is_file()
|
||||
assert CAPTURE_CDP.is_file()
|
||||
assert DERIVE.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_present(skill_text: str):
|
||||
assert skill_text.startswith("---\n")
|
||||
assert skill_text.count("---") >= 2
|
||||
|
||||
|
||||
def test_description_under_sixty_chars(skill_text: str):
|
||||
m = re.search(r"^description: (.*)$", skill_text, re.MULTILINE)
|
||||
assert m, "no description field"
|
||||
desc = m.group(1).strip()
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars (>60): {desc!r}"
|
||||
assert desc.endswith("."), "description should end with a period"
|
||||
|
||||
|
||||
def test_required_sections_present(skill_text: str):
|
||||
for heading in (
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
):
|
||||
assert heading in skill_text, f"missing section: {heading}"
|
||||
|
||||
|
||||
# --- behavioral: derivation logic -----------------------------------------
|
||||
|
||||
|
||||
def _make_har() -> dict:
|
||||
return {
|
||||
"log": {
|
||||
"entries": [
|
||||
{ # a JSON API call we want derived, with an id path segment
|
||||
"_resourceType": "fetch",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/v1/items/12345/reviews?limit=5",
|
||||
"queryString": [{"name": "limit", "value": "5"}],
|
||||
"headers": [
|
||||
{"name": "User-Agent", "value": "Mozilla/5.0 TestBrowser/1.0"},
|
||||
{"name": "accept", "value": "application/json"},
|
||||
{"name": "referer", "value": "https://example.com/"},
|
||||
],
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"text": '{"reviews":[{"id":1}]}',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ # a static asset we must filter out by default
|
||||
"_resourceType": "script",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://cdn.example.com/app.js",
|
||||
"queryString": [],
|
||||
"headers": [{"name": "User-Agent", "value": "Mozilla/5.0 TestBrowser/1.0"}],
|
||||
},
|
||||
"response": {"status": 200, "content": {"mimeType": "application/javascript"}},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_derives_endpoint_and_filters_static(tmp_path, capsys):
|
||||
mod = _load_module(DERIVE, "har_to_client_undertest")
|
||||
har = tmp_path / "t.har"
|
||||
har.write_text(json.dumps(_make_har()), encoding="utf-8")
|
||||
|
||||
import sys
|
||||
|
||||
argv = sys.argv
|
||||
try:
|
||||
sys.argv = ["har_to_client.py", str(har), "--host", "example.com"]
|
||||
rc = mod.main()
|
||||
finally:
|
||||
sys.argv = argv
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert rc == 0
|
||||
# id path segment collapsed to {id}
|
||||
assert "GET https://api.example.com/v1/items/{id}/reviews" in out
|
||||
# query param surfaced
|
||||
assert "limit = 5" in out
|
||||
# static JS filtered out
|
||||
assert "app.js" not in out
|
||||
# boring header dropped, useful one absent from list but UA promoted to hints
|
||||
assert "referer" not in out
|
||||
# replay hint carries the browser UA
|
||||
assert "User-Agent (send this): Mozilla/5.0 TestBrowser/1.0" in out
|
||||
|
||||
|
||||
def test_path_template_collapses_ids():
|
||||
mod = _load_module(DERIVE, "har_to_client_undertest2")
|
||||
assert mod.path_template("/v1/items/12345/x") == "/v1/items/{id}/x"
|
||||
assert mod.path_template("/v1/items/abc/x") == "/v1/items/abc/x"
|
||||
|
||||
|
||||
def test_capture_actions_parse_ok():
|
||||
# har_capture imports playwright at module top; only assert the file is
|
||||
# syntactically valid and exposes run_action without importing playwright.
|
||||
src = CAPTURE.read_text(encoding="utf-8")
|
||||
compile(src, str(CAPTURE), "exec")
|
||||
assert "def run_action(" in src
|
||||
assert 'record_har_content="embed"' in src
|
||||
|
||||
|
||||
def test_cdp_capture_is_valid_and_attaches_not_launches():
|
||||
# Covers the CDP pathway (cloud backends / /browser connect). Syntax-check
|
||||
# without importing playwright, and assert it attaches (connect_over_cdp)
|
||||
# and does NOT close a browser it doesn't own.
|
||||
src = CAPTURE_CDP.read_text(encoding="utf-8")
|
||||
compile(src, str(CAPTURE_CDP), "exec")
|
||||
assert "connect_over_cdp(" in src
|
||||
assert 'page.on("request"' in src and 'page.on("response"' in src
|
||||
# must not tear down a browser it merely attached to
|
||||
assert "browser.close()" not in src
|
||||
|
||||
|
||||
def test_skill_documents_all_browser_pathways(skill_text: str):
|
||||
# The skill must route every Hermes browser backend to the right capturer.
|
||||
for token in ("Browserbase", "Browser-Use", "Firecrawl", "browser connect",
|
||||
"har_capture_cdp.py", "connect_over_cdp"):
|
||||
assert token in skill_text, f"pathway coverage missing: {token}"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The `hermes-agent` skill is what a running Hermes knows about itself.
|
||||
|
||||
`website/` is never packaged, so an installed Hermes has no local copy of the
|
||||
user guide; skills ARE synced into `$HERMES_HOME/skills/`. The skill therefore
|
||||
does not try to restate the product — it routes to the published `llms.txt`,
|
||||
which is generated from the docs tree on every build and so can never be behind
|
||||
the feature set. These tests keep that routing honest: the index has to be where
|
||||
the skill says it is, and every reference has to be reachable, otherwise a
|
||||
shipped feature is invisible and the agent answers "Hermes can't do that."
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SKILL_DIR = REPO / "skills" / "autonomous-ai-agents" / "hermes-agent"
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
GENERATOR = REPO / "website" / "scripts" / "generate-llms-txt.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_every_referenced_file_exists(skill_text):
|
||||
"""Routing a question to a file that isn't there is a dead end."""
|
||||
targets = set(re.findall(r"`((?:references|templates)/[^`]+)`", skill_text))
|
||||
|
||||
assert targets, "the skill's routing table no longer references any files"
|
||||
for target in sorted(targets):
|
||||
assert (SKILL_DIR / target).exists(), f"SKILL.md routes to missing {target}"
|
||||
|
||||
|
||||
def test_every_reference_is_reachable_from_the_skill(skill_text):
|
||||
"""An unrouted reference is one the agent will never think to open.
|
||||
|
||||
This is the failure that produced the original complaint: content can exist
|
||||
and still be invisible because nothing points at it.
|
||||
"""
|
||||
on_disk = {f"references/{path.name}" for path in (SKILL_DIR / "references").glob("*.md")}
|
||||
routed = set(re.findall(r"`(references/[^`]+)`", skill_text))
|
||||
|
||||
assert not (on_disk - routed), (
|
||||
f"reference files no reader will ever reach: {sorted(on_disk - routed)} — "
|
||||
"add a routing-table row in SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_features_route_to_the_published_index(skill_text):
|
||||
"""The catch-all is what makes coverage of the whole product possible."""
|
||||
assert "/docs/llms.txt" in skill_text
|
||||
# web_extract can be disabled; terminal never is.
|
||||
assert "curl" in skill_text, "no way to reach the index without web tools"
|
||||
|
||||
|
||||
def test_the_index_is_published_where_the_skill_says_it_is(skill_text):
|
||||
"""A skill pointing at a URL nobody generates is worse than no routing."""
|
||||
spec = importlib.util.spec_from_file_location("generate_llms_txt", GENERATOR)
|
||||
assert spec is not None and spec.loader is not None
|
||||
gen = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gen)
|
||||
|
||||
assert f"{gen.SITE_BASE}/llms.txt" in skill_text
|
||||
@@ -0,0 +1,135 @@
|
||||
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_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_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"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for the mcp-oauth-remote-gateway optional skill.
|
||||
|
||||
Covers the diagnose-oauth-mcp.py decision tree (TOKEN_OK / REFRESH_FIXED /
|
||||
SESSION_REVOKED / REFRESH_DEAD), the HERMES_HOME resolution fallback, the
|
||||
atomic --write persistence path, and SKILL.md frontmatter invariants.
|
||||
No live network calls — urllib is mocked throughout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "mcp"
|
||||
/ "mcp-oauth-remote-gateway"
|
||||
)
|
||||
SCRIPT_PATH = SKILL_DIR / "scripts" / "diagnose-oauth-mcp.py"
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("diagnose_oauth_mcp", 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
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status=200, body=b"{}", headers=None):
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {}
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _write_token_files(tokens_dir: Path, server="stripe", resource="https://mcp.example.com",
|
||||
refresh_token="rt-1"):
|
||||
tokens_dir.mkdir(parents=True, exist_ok=True)
|
||||
tok = {
|
||||
"access_token": "at-stored",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "read",
|
||||
"resource": resource,
|
||||
"expires_at": 0,
|
||||
}
|
||||
if refresh_token is None:
|
||||
del tok["refresh_token"]
|
||||
(tokens_dir / f"{server}.json").write_text(json.dumps(tok))
|
||||
(tokens_dir / f"{server}.client.json").write_text(
|
||||
json.dumps({"client_id": "cid-1", "token_endpoint_auth_method": "none"})
|
||||
)
|
||||
return tok
|
||||
|
||||
|
||||
def _run_main(mod, tokens_dir, argv, responses):
|
||||
"""Run mod.main() with urlopen mocked; returns captured stdout.
|
||||
|
||||
``responses`` is a list consumed in call order; each item is either a
|
||||
FakeResponse or an Exception to raise.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
calls.append(req)
|
||||
item = responses.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
with patch.object(mod.os, "environ", dict(mod.os.environ, HERMES_HOME=str(tokens_dir.parent))), \
|
||||
patch.object(mod.urllib.request, "urlopen", side_effect=fake_urlopen), \
|
||||
patch.object(sys, "argv", ["diagnose-oauth-mcp.py", *argv]):
|
||||
# Force the env-var fallback path (ignore any importable hermes_constants).
|
||||
with patch.object(mod, "_hermes_home", lambda: str(tokens_dir.parent)):
|
||||
buf = io.StringIO()
|
||||
from contextlib import redirect_stdout
|
||||
with redirect_stdout(buf):
|
||||
mod.main()
|
||||
return buf.getvalue(), calls
|
||||
|
||||
|
||||
def _init_ok_body():
|
||||
return json.dumps({"jsonrpc": "2.0", "id": 1,
|
||||
"result": {"serverInfo": {"name": "x"}, "capabilities": {}}}).encode()
|
||||
|
||||
|
||||
def _init_revoked_error(code=401):
|
||||
body = json.dumps({"error": {"code": -32002, "message": "Session expired. Please re-authenticate."}}).encode()
|
||||
return urllib.error.HTTPError("https://mcp.example.com", code, "Unauthorized",
|
||||
{"WWW-Authenticate": 'Bearer error="invalid_token"'},
|
||||
io.BytesIO(body))
|
||||
|
||||
|
||||
def test_token_ok_branch(tmp_path):
|
||||
mod = load_module()
|
||||
tokens_dir = tmp_path / "mcp-tokens"
|
||||
_write_token_files(tokens_dir)
|
||||
out, calls = _run_main(mod, tokens_dir, ["stripe"], [FakeResponse(200, _init_ok_body())])
|
||||
assert "BRANCH=TOKEN_OK" in out
|
||||
assert len(calls) == 1 # never touched the token endpoint
|
||||
|
||||
|
||||
def test_refresh_dead_no_refresh_token(tmp_path):
|
||||
mod = load_module()
|
||||
tokens_dir = tmp_path / "mcp-tokens"
|
||||
_write_token_files(tokens_dir, refresh_token=None)
|
||||
out, _ = _run_main(mod, tokens_dir, ["stripe"], [_init_revoked_error()])
|
||||
assert "BRANCH=REFRESH_DEAD" in out
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_requests_send_httpx_user_agent(tmp_path):
|
||||
"""Cloudflare 403s bare urllib UAs — every request must carry the httpx UA."""
|
||||
mod = load_module()
|
||||
tokens_dir = tmp_path / "mcp-tokens"
|
||||
_write_token_files(tokens_dir)
|
||||
_, calls = _run_main(mod, tokens_dir, ["stripe"], [FakeResponse(200, _init_ok_body())])
|
||||
for req in calls:
|
||||
assert req.get_header("User-agent") == mod.UA
|
||||
|
||||
|
||||
def test_skill_md_frontmatter_invariants():
|
||||
yaml = pytest.importorskip("yaml")
|
||||
content = SKILL_MD.read_text()
|
||||
assert content.startswith("---\n")
|
||||
fm = yaml.safe_load(re.search(r"^---\n(.*?)\n---", content, re.DOTALL).group(1))
|
||||
assert len(fm["description"]) <= 60
|
||||
assert fm["description"].endswith(".")
|
||||
assert "platforms" in fm and len(fm["platforms"]) >= 1
|
||||
assert fm["author"].split(",")[0].strip() != "Hermes Agent" # human credited first
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for the meeting-action-items bundled skill."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "productivity"
|
||||
/ "meeting-action-items"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "meeting-action-items"
|
||||
hermes = fm["metadata"]["hermes"]
|
||||
assert hermes["tags"]
|
||||
assert "related_skills" in hermes
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent"), "human contributor must be credited first"
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(repo_root.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_no_phantom_connectors_in_prose():
|
||||
"""Prose must not name tracker connectors that don't exist as skills."""
|
||||
_, body = _frontmatter_and_body()
|
||||
assert not re.search(r"\bLinear\b", body), "no phantom 'Linear' connector references"
|
||||
|
||||
|
||||
def test_body_structure_and_size():
|
||||
_, body = _frontmatter_and_body()
|
||||
for section in ("## When to Use", "## Procedure", "## Pitfalls", "## Verification"):
|
||||
assert section in body, f"missing section: {section}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
assert not re.search(r"[A-Z]:\\\\Users", content)
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_core_disciplines_present():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "never invent" in body, "due-date invention ban must be present"
|
||||
assert "before creating anything" in body, "reconcile-before-create must be present"
|
||||
assert "`unresolved`" in body, "missing owners/dates stay visibly unresolved"
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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_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_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
|
||||
|
||||
|
||||
|
||||
|
||||
# ── 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_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"
|
||||
|
||||
|
||||
|
||||
|
||||
# ── 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"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Quiz Batch Add ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
# ── Statistics ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
# ── Edge Cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
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_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()
|
||||
|
||||
|
||||
|
||||
# ── User Answer Tracking ────────────────────────────────────────────────────
|
||||
|
||||
class TestUserAnswer:
|
||||
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Invariant tests for the bundled office/document skills.
|
||||
|
||||
Covers skills/productivity/{docx,xlsx,pdf,powerpoint} — the clean-room
|
||||
MIT office document suite. Tests assert contracts (frontmatter shape,
|
||||
referenced scripts exist, script CLI conventions, UTF-8-explicit I/O),
|
||||
not snapshots of skill content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent.parent
|
||||
SKILLS = REPO / "skills"
|
||||
|
||||
OFFICE_SKILLS = ["docx", "xlsx", "pdf", "powerpoint"]
|
||||
|
||||
|
||||
def _skill_dir(name: str) -> Path:
|
||||
return SKILLS / "productivity" / name
|
||||
|
||||
|
||||
def _frontmatter(skill_md: Path) -> dict:
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
|
||||
assert match, f"{skill_md} has no YAML frontmatter"
|
||||
return yaml.safe_load(match.group(1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_skill_exists_with_frontmatter(name):
|
||||
skill_md = _skill_dir(name) / "SKILL.md"
|
||||
assert skill_md.exists(), f"missing {skill_md}"
|
||||
fm = _frontmatter(skill_md)
|
||||
assert fm["name"] == name
|
||||
assert fm["description"].strip()
|
||||
assert len(fm["description"]) <= 60, (
|
||||
f"{name}: description is {len(fm['description'])} chars (max 60)"
|
||||
)
|
||||
assert fm["description"].rstrip('"').endswith(".")
|
||||
platforms = fm.get("platforms")
|
||||
assert platforms, f"{name}: missing platforms gating"
|
||||
assert set(platforms) <= {"linux", "macos", "windows"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_mit_licensed_clean_room(name):
|
||||
"""The office suite is the clean-room rewrite: MIT, no Anthropic
|
||||
license text, no proprietary license markers anywhere in the dir."""
|
||||
skill_dir = _skill_dir(name)
|
||||
fm = _frontmatter(skill_dir / "SKILL.md")
|
||||
assert str(fm.get("license", "")).strip() == "MIT", (
|
||||
f"{name}: license must be MIT, got {fm.get('license')!r}"
|
||||
)
|
||||
assert not (skill_dir / "LICENSE.txt").exists(), (
|
||||
f"{name}: legacy LICENSE.txt present — clean-room dirs ship LICENSE (MIT)"
|
||||
)
|
||||
license_file = skill_dir / "LICENSE"
|
||||
assert license_file.exists(), f"{name}: missing MIT LICENSE file"
|
||||
text = license_file.read_text(encoding="utf-8")
|
||||
assert "MIT License" in text
|
||||
assert "Anthropic" not in text
|
||||
for path in skill_dir.rglob("*"):
|
||||
if path.is_file() and path.suffix in (".md", ".py"):
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
assert "Anthropic" not in content, (
|
||||
f"{name}: {path.relative_to(skill_dir)} references Anthropic — "
|
||||
"clean-room provenance violation"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_referenced_scripts_exist(name):
|
||||
"""Every scripts/... path mentioned in SKILL.md must exist on disk."""
|
||||
skill_dir = _skill_dir(name)
|
||||
body = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
refs = set(re.findall(r"scripts/[\w./-]+\.py", body))
|
||||
assert refs, f"{name}: SKILL.md references no helper scripts"
|
||||
for ref in refs:
|
||||
assert (skill_dir / ref).exists(), f"{name}: SKILL.md references missing {ref}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_all_shipped_scripts_are_documented(name):
|
||||
"""Every shipped scripts/*.py is mentioned in SKILL.md (no dead cargo).
|
||||
Shared/internal modules (underscore-prefixed or *_common.py) are exempt."""
|
||||
skill_dir = _skill_dir(name)
|
||||
body = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
for script in (skill_dir / "scripts").glob("*.py"):
|
||||
if script.name.startswith("_") or script.stem.endswith("_common"):
|
||||
continue
|
||||
assert script.name in body, (
|
||||
f"{name}: scripts/{script.name} is shipped but never mentioned in SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_scripts_use_explicit_utf8_text_io(name):
|
||||
"""No locale-default text-mode I/O in helper scripts: every text-mode
|
||||
open() must pass encoding=. Binary-mode opens are exempt. This is the
|
||||
class of bug that mojibake'd form fills on cp1251/GBK/cp932 hosts."""
|
||||
skill_dir = _skill_dir(name)
|
||||
offenders = []
|
||||
for script in (skill_dir / "scripts").rglob("*.py"):
|
||||
content = script.read_text(encoding="utf-8")
|
||||
for m in re.finditer(r"(?<![\w.])open\(([^)]*)\)", content):
|
||||
args = m.group(1)
|
||||
if re.search(r"['\"][rwaxt+]*b[rwaxt+]*['\"]", args):
|
||||
continue # binary mode
|
||||
if "encoding" not in args:
|
||||
line = content[: m.start()].count("\n") + 1
|
||||
offenders.append(f"{script.relative_to(skill_dir)}:{line}: open({args})")
|
||||
assert not offenders, (
|
||||
f"{name}: text-mode open() without explicit encoding:\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_scripts_are_argparse_clis(name):
|
||||
"""Helper scripts are argparse CLIs: importable arg parsing + a main
|
||||
guard, so `python scripts/x.py --help` works everywhere."""
|
||||
skill_dir = _skill_dir(name)
|
||||
for script in (skill_dir / "scripts").glob("*.py"):
|
||||
if script.name.startswith("_") or script.stem.endswith("_common"):
|
||||
continue
|
||||
content = script.read_text(encoding="utf-8")
|
||||
assert "argparse" in content, f"{name}: scripts/{script.name} is not an argparse CLI"
|
||||
assert '__name__' in content, f"{name}: scripts/{script.name} lacks a __main__ guard"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", OFFICE_SKILLS)
|
||||
def test_skill_has_tests(name):
|
||||
"""Each office skill ships its own e2e pytest suite."""
|
||||
tests_dir = _skill_dir(name) / "tests"
|
||||
assert tests_dir.is_dir(), f"{name}: missing tests/ directory"
|
||||
assert list(tests_dir.glob("test_*.py")), f"{name}: no test files in tests/"
|
||||
|
||||
|
||||
def test_docs_pages_generated():
|
||||
"""Each bundled office skill has a generated docs-site page."""
|
||||
docs_dir = REPO / "website" / "docs" / "user-guide" / "skills" / "bundled" / "productivity"
|
||||
for name in OFFICE_SKILLS:
|
||||
assert (docs_dir / f"productivity-{name}.md").exists(), (
|
||||
f"missing generated docs page for {name}; run website/scripts/generate-skill-docs.py"
|
||||
)
|
||||
@@ -0,0 +1,750 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
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_module():
|
||||
spec = importlib.util.spec_from_file_location("openclaw_to_hermes", 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 load_skills_guard():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"skills_guard_local",
|
||||
Path(__file__).resolve().parents[2] / "tools" / "skills_guard.py",
|
||||
)
|
||||
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_extract_markdown_entries_promotes_heading_context():
|
||||
mod = load_module()
|
||||
text = """# MEMORY.md - Long-Term Memory
|
||||
|
||||
## Tyler Williams
|
||||
|
||||
- Founder of VANTA Research
|
||||
- Timezone: America/Los_Angeles
|
||||
|
||||
### Active Projects
|
||||
|
||||
- Hermes Agent
|
||||
"""
|
||||
entries = mod.extract_markdown_entries(text)
|
||||
assert "Tyler Williams: Founder of VANTA Research" in entries
|
||||
assert "Tyler Williams: Timezone: America/Los_Angeles" in entries
|
||||
assert "Tyler Williams > Active Projects: Hermes Agent" in entries
|
||||
|
||||
|
||||
|
||||
|
||||
def test_merge_entries_respects_limit_and_reports_overflow():
|
||||
mod = load_module()
|
||||
existing = ["alpha"]
|
||||
incoming = ["beta", "gamma is too long"]
|
||||
merged, stats, overflowed = mod.merge_entries(existing, incoming, limit=12)
|
||||
assert merged == ["alpha", "beta"]
|
||||
assert stats["added"] == 1
|
||||
assert stats["overflowed"] == 1
|
||||
assert overflowed == ["gamma is too long"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_migrator_copies_skill_and_merges_allowlist(tmp_path: Path):
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
|
||||
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
|
||||
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "exec-approvals.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"*": {
|
||||
"allowlist": [
|
||||
{"pattern": "/usr/bin/*"},
|
||||
{"pattern": "/home/test/**"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(target / "config.yaml").write_text("command_allowlist:\n - /usr/bin/*\n", encoding="utf-8")
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=target / "migration-report",
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" / "SKILL.md"
|
||||
assert imported_skill.exists()
|
||||
assert "/home/test/**" in (target / "config.yaml").read_text(encoding="utf-8")
|
||||
assert report["summary"]["migrated"] >= 2
|
||||
# The merge is written atomically — no temp file survives the run.
|
||||
assert [p.name for p in target.glob(".tmp*")] == []
|
||||
|
||||
|
||||
def _allowlist_migrator(mod, tmp_path: Path, existing_config: str):
|
||||
"""Migrator wired to merge an exec-approvals allowlist into config.yaml."""
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir(parents=True)
|
||||
(source / "exec-approvals.json").write_text(
|
||||
json.dumps({"agents": {"*": {"allowlist": [{"pattern": "/home/test/**"}]}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(target / "config.yaml").write_text(existing_config, encoding="utf-8")
|
||||
return mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=target / "migration-report",
|
||||
), target / "config.yaml"
|
||||
|
||||
|
||||
MALFORMED_HERMES_CONFIG = """\
|
||||
model: hermes-4-405b
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
command_allowlist:
|
||||
- /usr/bin/*
|
||||
approvals:
|
||||
deny: [shutdown *
|
||||
telegram:
|
||||
enabled: true
|
||||
"""
|
||||
|
||||
|
||||
def test_unreadable_config_is_refused_not_overwritten(tmp_path: Path):
|
||||
"""A present-but-unparseable config.yaml must survive the migration.
|
||||
|
||||
``load_yaml_file`` returned ``{}`` for an absent file AND for one it could
|
||||
not parse; the config-mutating steps read, merge and write the whole
|
||||
mapping back, so a YAML syntax error meant every existing setting was
|
||||
replaced by just the merged section. Same defect as the ported twin in
|
||||
``hermes_cli/agent_import.py``.
|
||||
"""
|
||||
mod = load_module()
|
||||
migrator, config_path = _allowlist_migrator(
|
||||
mod, tmp_path, MALFORMED_HERMES_CONFIG)
|
||||
before = config_path.read_bytes()
|
||||
|
||||
report = migrator.migrate()
|
||||
|
||||
assert config_path.read_bytes() == before
|
||||
allowlist = [i for i in report["items"] if i["kind"] == "command-allowlist"]
|
||||
assert allowlist and allowlist[0]["status"] == mod.STATUS_ERROR
|
||||
assert "not valid YAML" in allowlist[0]["reason"]
|
||||
|
||||
|
||||
def test_unreadable_config_blocks_later_config_steps_instead_of_partial_writes(
|
||||
tmp_path: Path):
|
||||
"""One refusal flips the existing _config_apply_blocked short-circuit."""
|
||||
mod = load_module()
|
||||
migrator, config_path = _allowlist_migrator(
|
||||
mod, tmp_path, MALFORMED_HERMES_CONFIG)
|
||||
|
||||
report = migrator.migrate()
|
||||
|
||||
assert migrator._config_apply_blocked is True
|
||||
statuses = {
|
||||
i["status"] for i in report["items"]
|
||||
if i["kind"] in mod.Migrator._CONFIG_MUTATING_OPTIONS
|
||||
}
|
||||
# Nothing claimed a successful config write.
|
||||
assert "migrated" not in statuses
|
||||
assert config_path.read_text(encoding="utf-8") == MALFORMED_HERMES_CONFIG
|
||||
|
||||
|
||||
def test_readable_config_keeps_every_pre_existing_key(tmp_path: Path):
|
||||
mod = load_module()
|
||||
migrator, config_path = _allowlist_migrator(
|
||||
mod,
|
||||
tmp_path,
|
||||
"model: hermes-4-405b\n"
|
||||
"api_key_env: OPENROUTER_API_KEY\n"
|
||||
"command_allowlist:\n - /usr/bin/*\n",
|
||||
)
|
||||
|
||||
migrator.migrate()
|
||||
|
||||
import yaml
|
||||
|
||||
merged = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert merged["model"] == "hermes-4-405b"
|
||||
assert merged["api_key_env"] == "OPENROUTER_API_KEY"
|
||||
assert "/usr/bin/*" in merged["command_allowlist"]
|
||||
assert "/home/test/**" in merged["command_allowlist"]
|
||||
|
||||
|
||||
def test_absent_config_is_still_created(tmp_path: Path):
|
||||
"""The guard must not break first-time creation.
|
||||
|
||||
Only ``absent`` may read as ``{}``; ``model-config`` creates config.yaml
|
||||
from scratch when the target has none.
|
||||
"""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({"agents": {"defaults": {"model": "anthropic/claude-sonnet-4"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = target / "config.yaml"
|
||||
assert not config_path.exists()
|
||||
|
||||
mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=True, migrate_secrets=False,
|
||||
output_dir=None, selected_options={"model-config"},
|
||||
).migrate()
|
||||
|
||||
assert "anthropic/claude-sonnet-4" in config_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_symlinked_config_stays_a_symlink(tmp_path: Path):
|
||||
"""Managed deployments symlink ~/.hermes/config.yaml into a dotfiles repo.
|
||||
|
||||
A plain ``os.replace`` onto the link would detach it into a regular file;
|
||||
``dump_yaml_file`` resolves the link first, as ``utils.atomic_replace`` does.
|
||||
"""
|
||||
mod = load_module()
|
||||
real = tmp_path / "dotfiles" / "config.yaml"
|
||||
real.parent.mkdir(parents=True)
|
||||
real.write_text("model: hermes-4-405b\ncommand_allowlist:\n - /usr/bin/*\n",
|
||||
encoding="utf-8")
|
||||
migrator, config_path = _allowlist_migrator(mod, tmp_path, "placeholder: true\n")
|
||||
config_path.unlink()
|
||||
config_path.symlink_to(real)
|
||||
|
||||
migrator.migrate()
|
||||
|
||||
assert config_path.is_symlink()
|
||||
assert config_path.resolve() == real.resolve()
|
||||
assert "/home/test/**" in real.read_text(encoding="utf-8")
|
||||
assert "hermes-4-405b" in real.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_unreadable_config_refused_by_model_config_too(tmp_path: Path):
|
||||
"""The refusal is at the shared helper, so every config step inherits it."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({"agents": {"defaults": {"model": "anthropic/claude-sonnet-4"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = target / "config.yaml"
|
||||
config_path.write_text(MALFORMED_HERMES_CONFIG, encoding="utf-8")
|
||||
|
||||
report = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=True, migrate_secrets=False,
|
||||
output_dir=None, selected_options={"model-config"},
|
||||
).migrate()
|
||||
|
||||
assert config_path.read_text(encoding="utf-8") == MALFORMED_HERMES_CONFIG
|
||||
items = [i for i in report["items"] if i["kind"] == "model-config"]
|
||||
assert items and items[0]["status"] == mod.STATUS_ERROR
|
||||
|
||||
|
||||
def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tmp_path: Path):
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
|
||||
(source / "credentials").mkdir(parents=True)
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": "/tmp/openclaw-workspace"}},
|
||||
"channels": {"telegram": {"botToken": "123:abc"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "credentials" / "telegram-default-allowFrom.json").write_text(
|
||||
json.dumps({"allowFrom": ["111", "222"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.mkdir()
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=True,
|
||||
output_dir=target / "migration-report",
|
||||
)
|
||||
migrator.migrate()
|
||||
|
||||
env_text = (target / ".env").read_text(encoding="utf-8")
|
||||
assert "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text
|
||||
assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text
|
||||
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_source_candidate_finds_files_in_custom_workspace(tmp_path: Path):
|
||||
"""When agents.defaults.workspace points outside ~/.openclaw, files should
|
||||
be discovered there as a fallback."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
custom_ws = tmp_path / "my-custom-workspace"
|
||||
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
custom_ws.mkdir()
|
||||
|
||||
# No workspace/ directory inside .openclaw — files live in custom workspace
|
||||
(custom_ws / "MEMORY.md").write_text("# Memory\n\n- custom workspace entry\n", encoding="utf-8")
|
||||
(custom_ws / "SOUL.md").write_text("# Soul\n\nI am me.\n", encoding="utf-8")
|
||||
(custom_ws / "skills" / "my-skill").mkdir(parents=True)
|
||||
(custom_ws / "skills" / "my-skill" / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: test\n---\n\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(custom_ws / "memory").mkdir()
|
||||
(custom_ws / "memory" / "2026-01-01.md").write_text("- daily note\n", encoding="utf-8")
|
||||
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(custom_ws)}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=target / "migration-report",
|
||||
selected_options={"soul", "memory", "skills", "daily-memory"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
# SOUL.md should have been found and migrated
|
||||
assert (target / "SOUL.md").exists()
|
||||
|
||||
# MEMORY.md should have been found and migrated
|
||||
assert (target / "memories" / "MEMORY.md").exists()
|
||||
mem_content = (target / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
||||
assert "custom workspace entry" in mem_content
|
||||
|
||||
# Skills should have been found and migrated
|
||||
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "my-skill" / "SKILL.md"
|
||||
assert imported_skill.exists()
|
||||
|
||||
migrated_kinds = {item["kind"] for item in report["items"] if item["status"] == "migrated"}
|
||||
assert "soul" in migrated_kinds
|
||||
assert "memory" in migrated_kinds
|
||||
assert "skill" in migrated_kinds
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_slack_settings_migrated(tmp_path: Path):
|
||||
"""Slack bot/app tokens and allowlist migrate to .env."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({
|
||||
"channels": {
|
||||
"slack": {
|
||||
"botToken": "xoxb-slack-bot",
|
||||
"appToken": "xapp-slack-app",
|
||||
"allowFrom": ["U111", "U222"],
|
||||
}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"slack-settings"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
env_text = (target / ".env").read_text(encoding="utf-8")
|
||||
assert "SLACK_BOT_TOKEN=xoxb-slack-bot" in env_text
|
||||
assert "SLACK_APP_TOKEN=xapp-slack-app" in env_text
|
||||
assert "SLACK_ALLOWED_USERS=U111,U222" in env_text
|
||||
|
||||
|
||||
|
||||
|
||||
def test_model_config_migrated(tmp_path: Path):
|
||||
"""Default model setting migrates to config.yaml."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({
|
||||
"agents": {"defaults": {"model": "anthropic/claude-sonnet-4"}}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# config.yaml must exist for YAML merge to work
|
||||
(target / "config.yaml").write_text("model: openrouter/auto\n", encoding="utf-8")
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=True, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"model-config"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
config_text = (target / "config.yaml").read_text(encoding="utf-8")
|
||||
assert "anthropic/claude-sonnet-4" in config_text
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_shared_skills_migrated(tmp_path: Path):
|
||||
"""Shared skills from ~/.openclaw/skills/ are migrated."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
|
||||
# Create a shared skill (not in workspace/skills/)
|
||||
(source / "skills" / "my-shared-skill").mkdir(parents=True)
|
||||
(source / "skills" / "my-shared-skill" / "SKILL.md").write_text(
|
||||
"---\nname: my-shared-skill\ndescription: shared\n---\n\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"shared-skills"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
imported = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "my-shared-skill" / "SKILL.md"
|
||||
assert imported.exists()
|
||||
|
||||
|
||||
def test_daily_memory_merged(tmp_path: Path):
|
||||
"""Daily memory notes from workspace/memory/*.md are merged into MEMORY.md."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
|
||||
mem_dir = source / "workspace" / "memory"
|
||||
mem_dir.mkdir(parents=True)
|
||||
(mem_dir / "2026-03-01.md").write_text(
|
||||
"# March 1 Notes\n\n- User prefers dark mode\n- Timezone: PST\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(mem_dir / "2026-03-02.md").write_text(
|
||||
"# March 2 Notes\n\n- Working on migration project\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"daily-memory"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
mem_path = target / "memories" / "MEMORY.md"
|
||||
assert mem_path.exists()
|
||||
content = mem_path.read_text(encoding="utf-8")
|
||||
assert "dark mode" in content
|
||||
assert "migration project" in content
|
||||
|
||||
|
||||
def test_provider_keys_require_migrate_secrets_flag(tmp_path: Path):
|
||||
"""Provider keys migration is double-gated: needs option + --migrate-secrets."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
source.mkdir()
|
||||
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({
|
||||
"models": {
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-test-key",
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Without --migrate-secrets: should skip
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"provider-keys"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
env_path = target / ".env"
|
||||
if env_path.exists():
|
||||
assert "sk-or-test-key" not in env_path.read_text(encoding="utf-8")
|
||||
|
||||
# With --migrate-secrets: should import
|
||||
migrator2 = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=True, output_dir=None,
|
||||
selected_options={"provider-keys"},
|
||||
)
|
||||
report2 = migrator2.migrate()
|
||||
env_text = (target / ".env").read_text(encoding="utf-8")
|
||||
assert "OPENROUTER_API_KEY=sk-or-test-key" in env_text
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_skill_installs_cleanly_under_skills_guard():
|
||||
skills_guard = load_skills_guard()
|
||||
result = skills_guard.scan_skill(
|
||||
SCRIPT_PATH.parents[1],
|
||||
source="official/migration/openclaw-migration",
|
||||
)
|
||||
|
||||
# The migration script's references to agent config files are legitimate:
|
||||
# it mentions AGENTS.md to migrate workspace instructions and points the
|
||||
# user at ~/.hermes/config.yaml in its post-migration summary — it never
|
||||
# writes to either. Under skills-guard-v2 (#92021) these score as
|
||||
# informational _ref findings (the old critical agent_config_mod /
|
||||
# hermes_config_mod findings no longer fire for bare mentions), so the
|
||||
# verdict is "safe" with no modification-intent findings at all.
|
||||
assert result.verdict == "safe", f"Unexpected verdict: {result.verdict}"
|
||||
KNOWN_FALSE_POSITIVES = {"agent_config_ref", "hermes_config_ref"}
|
||||
for f in result.findings:
|
||||
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
|
||||
|
||||
|
||||
# ── rebrand_text tests ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rebrand_text_replaces_openclaw_variants():
|
||||
mod = load_module()
|
||||
# Mixed-case / capitalized matches → capital-H ``Hermes``.
|
||||
assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11"
|
||||
assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode"
|
||||
assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great"
|
||||
assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well"
|
||||
# All-lowercase matches → lowercase ``hermes``; this preserves the
|
||||
# real filesystem path ``~/.hermes`` (Hermes home) when rebranding
|
||||
# memory entries that reference ``~/.openclaw`` or ``openclaw`` prose.
|
||||
assert mod.rebrand_text("openclaw should always respond concisely") == "hermes should always respond concisely"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── migrate_model_config: alias resolution (issue #16745) ──────────────────
|
||||
|
||||
def _run_model_migration(tmp_path: Path, openclaw_json: dict) -> dict:
|
||||
"""Helper: run just migrate_model_config on an openclaw.json and return
|
||||
the parsed destination config.yaml."""
|
||||
import yaml
|
||||
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
source.mkdir(parents=True)
|
||||
target.mkdir(parents=True)
|
||||
(source / "openclaw.json").write_text(json.dumps(openclaw_json), encoding="utf-8")
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=True,
|
||||
migrate_secrets=False,
|
||||
output_dir=target / "migration-report",
|
||||
)
|
||||
migrator.migrate_model_config()
|
||||
|
||||
cfg_path = target / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return {}
|
||||
return yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def _extract_model(parsed: dict) -> str | None:
|
||||
model = parsed.get("model")
|
||||
if isinstance(model, dict):
|
||||
return model.get("default")
|
||||
return model
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── non-UTF-8 tolerance (issue #8901) ───────────────────────────────────────
|
||||
|
||||
|
||||
def _write_invalid_utf8_json(path: Path, prefix: bytes, valid_value: bytes, suffix: bytes) -> None:
|
||||
"""Write a JSON-shaped file containing one invalid UTF-8 byte (0xB3) inside
|
||||
a string value, alongside a separate, validly-encoded value. Used to check
|
||||
that a single bad byte does not prevent the rest of the file's data from
|
||||
being read (relies on read_text(..., errors="replace"))."""
|
||||
path.write_bytes(prefix + b"\xb3" + valid_value + suffix)
|
||||
|
||||
|
||||
def test_command_allowlist_handles_invalid_utf8_bytes(tmp_path: Path):
|
||||
"""exec-approvals.json with a non-UTF-8 byte should not abort migration;
|
||||
valid patterns elsewhere in the same file must still be imported."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
_write_invalid_utf8_json(
|
||||
source / "exec-approvals.json",
|
||||
prefix=b'{"agents": {"*": {"allowlist": [{"pattern": "/bad',
|
||||
valid_value=b'"}, {"pattern": "/usr/bin/*"}]}}}',
|
||||
suffix=b"",
|
||||
)
|
||||
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"command-allowlist"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
items = [i for i in report["items"] if i["kind"] == "command-allowlist"]
|
||||
assert items and items[0]["status"] == "migrated"
|
||||
config_text = (target / "config.yaml").read_text(encoding="utf-8")
|
||||
assert "/usr/bin/*" in config_text
|
||||
|
||||
|
||||
def test_messaging_settings_handles_invalid_utf8_in_telegram_allowlist(tmp_path: Path):
|
||||
"""Telegram allowFrom file with a non-UTF-8 byte should not abort migration;
|
||||
valid user IDs elsewhere in the same file must still be imported."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
creds_dir = source / "credentials"
|
||||
creds_dir.mkdir()
|
||||
_write_invalid_utf8_json(
|
||||
creds_dir / "telegram-default-allowFrom.json",
|
||||
prefix=b'{"allowFrom": ["bad',
|
||||
valid_value=b'", "123456789"]}',
|
||||
suffix=b"",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source, target_root=target, execute=True,
|
||||
workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
|
||||
selected_options={"messaging-settings"},
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
items = [i for i in report["items"] if i["kind"] == "messaging-settings"]
|
||||
assert items and items[0]["status"] == "migrated"
|
||||
env_text = (target / ".env").read_text(encoding="utf-8")
|
||||
assert "123456789" in env_text
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""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_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_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_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_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_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_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_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,82 @@
|
||||
"""
|
||||
Smoke tests for the pinecone-research optional skill.
|
||||
|
||||
Validates:
|
||||
- SKILL.md frontmatter conforms to the ≤60-char description standard
|
||||
- The skill name is distinct from the existing mlops/pinecone skill
|
||||
- Frontmatter has required fields
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "research"
|
||||
/ "pinecone-research"
|
||||
)
|
||||
|
||||
MLOPS_PINECONE_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "mlops"
|
||||
/ "pinecone"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frontmatter() -> dict:
|
||||
src = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
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_description_under_60_chars(frontmatter) -> None:
|
||||
desc = frontmatter["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars (limit ≤60): {desc!r}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_has_required_frontmatter_fields(frontmatter) -> None:
|
||||
for field in ("name", "description", "version", "license"):
|
||||
assert field in frontmatter, f"missing required field: {field}"
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"scripts/rag_pipeline.py",
|
||||
"scripts/memory_manager.py",
|
||||
],
|
||||
)
|
||||
def test_shipped_scripts_parse(path: str) -> None:
|
||||
"""Shipped scripts must be valid Python (ast.parse raises SyntaxError otherwise)."""
|
||||
import ast
|
||||
|
||||
src = (SKILL_DIR / path).read_text(encoding="utf-8")
|
||||
ast.parse(src) # raises SyntaxError on broken Python
|
||||
|
||||
|
||||
def test_scripts_use_pinecone_client() -> None:
|
||||
"""Both scripts should reference the Pinecone SDK."""
|
||||
for script in ("scripts/rag_pipeline.py", "scripts/memory_manager.py"):
|
||||
src = (SKILL_DIR / script).read_text(encoding="utf-8")
|
||||
assert "PINECONE_API_KEY" in src, f"{script} should read PINECONE_API_KEY"
|
||||
assert "Pinecone" in src, f"{script} should import/use Pinecone client"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for the product-price-monitor skill and its price-watch blueprint."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "productivity"
|
||||
/ "product-price-monitor"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "product-price-monitor"
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent")
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(repo_root.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_no_phantom_connectors_in_prose():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "flight-research" not in body, "no phantom 'flight-research' skill references"
|
||||
|
||||
|
||||
def test_setup_tick_split():
|
||||
"""The skill must separate one-time setup from the recurring cron tick."""
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "Setup (foreground, once)" in body
|
||||
assert "Tick (each scheduled run)" in body
|
||||
assert "cronjob(action=" in body, "must wire scheduling through the cronjob tool"
|
||||
assert "Do not schedule until one foreground fetch works" in body
|
||||
|
||||
|
||||
def test_state_discipline_present():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "never overwrite the last good observation" in body
|
||||
assert "fingerprint" in body
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 5
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert not re.search(r"/home/(?!.*price-watches)", content)
|
||||
assert "/home/bb" not in content
|
||||
|
||||
|
||||
def test_price_watch_blueprint_registered():
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
bp = next((b for b in CATALOG if b.key == "price-watch"), None)
|
||||
assert bp is not None, "price-watch blueprint missing from catalog"
|
||||
assert "product-price-monitor" in bp.skills, "blueprint must load the skill"
|
||||
slot_names = {s.name for s in bp.slots}
|
||||
assert {"item", "condition", "interval_h", "deliver"} <= slot_names
|
||||
assert "[SILENT]" in bp.prompt_template, "silent path must be explicit"
|
||||
assert "{item}" in bp.prompt_template and "{condition}" in bp.prompt_template
|
||||
|
||||
|
||||
def test_price_watch_blueprint_schedule_resolves():
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
bp = next(b for b in CATALOG if b.key == "price-watch")
|
||||
interval_slot = next(s for s in bp.slots if s.name == "interval_h")
|
||||
for opt in interval_slot.options:
|
||||
expr = bp.schedule_template.format(interval_h=opt)
|
||||
fields = expr.split()
|
||||
assert len(fields) == 5, f"invalid cron expr: {expr}"
|
||||
assert fields[1] == f"*/{opt}"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for the optional-skills/web-development/publish-site skill.
|
||||
|
||||
Structural + internal-consistency checks only (stdlib + pytest, no network).
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SKILL_DIR = REPO / "optional-skills" / "web-development" / "publish-site"
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
|
||||
VALID_PLATFORMS = {"linux", "macos", "windows"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def frontmatter(skill_text: str) -> dict:
|
||||
"""Parse the YAML frontmatter with stdlib only (flat key: value fields)."""
|
||||
m = re.match(r"^---\n(.*?)\n---\n", skill_text, re.DOTALL)
|
||||
assert m, "SKILL.md must open with '---' delimited YAML frontmatter"
|
||||
fm: dict[str, str] = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if line.startswith((" ", "\t")) or ":" not in line:
|
||||
continue # nested metadata keys — not needed here
|
||||
key, _, value = line.partition(":")
|
||||
fm[key.strip()] = value.strip()
|
||||
return fm
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_MD.is_file(), f"missing {SKILL_MD}"
|
||||
|
||||
|
||||
def test_frontmatter_parses(frontmatter: dict):
|
||||
assert frontmatter.get("name") == "publish-site"
|
||||
assert frontmatter.get("version"), "version field required"
|
||||
assert frontmatter.get("license") == "MIT"
|
||||
assert "Hermes Agent" in frontmatter.get("author", "")
|
||||
|
||||
|
||||
def test_description_length_and_period(frontmatter: dict):
|
||||
desc = frontmatter.get("description", "").strip().strip('"')
|
||||
assert desc, "no description field"
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars (>60): {desc!r}"
|
||||
assert desc.endswith("."), "description must end with a period"
|
||||
|
||||
|
||||
def test_platforms_list_valid(frontmatter: dict):
|
||||
raw = frontmatter.get("platforms", "")
|
||||
platforms = [p.strip() for p in raw.strip("[]").split(",") if p.strip()]
|
||||
assert platforms, "platforms list must be non-empty"
|
||||
assert set(platforms) <= VALID_PLATFORMS, f"invalid platforms: {platforms}"
|
||||
# gh/wrangler/netlify are all cross-platform — the skill keeps all three.
|
||||
assert set(platforms) == VALID_PLATFORMS
|
||||
|
||||
|
||||
def test_required_sections_present(skill_text: str):
|
||||
for heading in (
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
):
|
||||
assert heading in skill_text, f"missing section: {heading}"
|
||||
|
||||
|
||||
def test_no_dangling_skill_view_references(skill_text: str):
|
||||
"""Every skill_view(name='...') reference must target a shipped skill."""
|
||||
targets = re.findall(r"skill_view\(\s*name\s*=\s*['\"]([^'\"]+)['\"]", skill_text)
|
||||
for name in targets:
|
||||
hits = list((REPO / "skills").glob(f"**/{name}/SKILL.md")) + list(
|
||||
(REPO / "optional-skills").glob(f"**/{name}/SKILL.md")
|
||||
)
|
||||
assert hits, f"skill_view reference to non-shipped skill: {name}"
|
||||
|
||||
|
||||
def test_referenced_sibling_skills_ship(skill_text: str):
|
||||
"""Prose-referenced companion skills must exist in the shipped trees."""
|
||||
for name in ("cloudflare-temporary-deploy",):
|
||||
if name in skill_text:
|
||||
hits = list((REPO / "skills").glob(f"**/{name}/SKILL.md")) + list(
|
||||
(REPO / "optional-skills").glob(f"**/{name}/SKILL.md")
|
||||
)
|
||||
assert hits, f"referenced skill does not ship: {name}"
|
||||
|
||||
|
||||
def test_provider_ladder_documented(skill_text: str):
|
||||
"""All three rungs of the provider ladder with their deploy commands."""
|
||||
assert "gh repo create" in skill_text
|
||||
assert "gh-pages" in skill_text
|
||||
assert "wrangler@latest pages deploy" in skill_text
|
||||
assert "netlify deploy --prod" in skill_text
|
||||
|
||||
|
||||
def test_version_before_deploy_discipline(skill_text: str):
|
||||
assert "git tag" in skill_text, "deploys must be tagged"
|
||||
assert "Never deploy uncommitted files" in skill_text
|
||||
|
||||
|
||||
def test_rollback_documented(skill_text: str):
|
||||
assert "Rollback" in skill_text
|
||||
assert re.search(r"git checkout deploy-", skill_text), "rollback must redeploy a previous tag"
|
||||
|
||||
|
||||
def test_secrets_never_in_repo(skill_text: str):
|
||||
assert "NEVER commit secrets" in skill_text
|
||||
assert ".gitignore" in skill_text
|
||||
|
||||
|
||||
def test_spa_404_pitfall_documented(skill_text: str):
|
||||
assert "404.html" in skill_text
|
||||
assert "_redirects" in skill_text
|
||||
|
||||
|
||||
def test_verification_uses_real_http_check(skill_text: str):
|
||||
assert "%{http_code}" in skill_text
|
||||
assert "200" in skill_text
|
||||
assert "deploy log alone" in skill_text
|
||||
|
||||
|
||||
def test_preview_before_deploy(skill_text: str):
|
||||
assert "http.server" in skill_text
|
||||
assert "cloudflared tunnel --url" in skill_text
|
||||
assert "trycloudflare.com" in skill_text
|
||||
|
||||
|
||||
def test_category_description_exists():
|
||||
desc = REPO / "optional-skills" / "web-development" / "DESCRIPTION.md"
|
||||
assert desc.is_file(), "optional web-development category needs a DESCRIPTION.md"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Contract tests for the bundled SDLC review skill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_MD = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills"
|
||||
/ "devops"
|
||||
/ "sdlc-review"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
REQUIRED_SECTIONS = [
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Review Lenses",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
]
|
||||
REVIEW_ACTIONS = {
|
||||
"kanban_show",
|
||||
"kanban_comment",
|
||||
"kanban_complete",
|
||||
"kanban_request_changes",
|
||||
"kanban_block",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _frontmatter_value(text: str, key: str) -> str:
|
||||
match = re.search(rf"^{re.escape(key)}:\s*(.+)$", text, re.MULTILINE)
|
||||
assert match, f"missing frontmatter field: {key}"
|
||||
return match.group(1).strip()
|
||||
|
||||
|
||||
def test_frontmatter_meets_hardline_standard(skill_text: str) -> None:
|
||||
assert skill_text.startswith("---\n")
|
||||
assert _frontmatter_value(skill_text, "name") == "sdlc-review"
|
||||
|
||||
description = _frontmatter_value(skill_text, "description")
|
||||
assert len(description) <= 60
|
||||
assert description.endswith(".")
|
||||
|
||||
for field in ("version", "author", "license", "platforms"):
|
||||
assert _frontmatter_value(skill_text, field)
|
||||
assert not _frontmatter_value(skill_text, "author").startswith("Hermes Agent")
|
||||
|
||||
|
||||
def test_body_uses_required_modern_section_order(skill_text: str) -> None:
|
||||
assert "# SDLC Review Skill" in skill_text
|
||||
positions = [skill_text.index(section) for section in REQUIRED_SECTIONS]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", sorted(REVIEW_ACTIONS))
|
||||
def test_skill_documents_native_review_actions(
|
||||
skill_text: str,
|
||||
tool_name: str,
|
||||
) -> None:
|
||||
assert f"`{tool_name}`" in skill_text
|
||||
|
||||
|
||||
def test_verdicts_route_through_distinct_terminal_actions(skill_text: str) -> None:
|
||||
quick_reference = skill_text.split("## Quick Reference", 1)[1].split(
|
||||
"## Review Lenses", 1
|
||||
)[0]
|
||||
assert "Approve" in quick_reference and "`kanban_complete`" in quick_reference
|
||||
assert "Request changes" in quick_reference
|
||||
assert "`kanban_request_changes`" in quick_reference
|
||||
assert "Escalate" in quick_reference and "`kanban_block`" in quick_reference
|
||||
|
||||
|
||||
def test_review_lenses_vary_per_round(skill_text: str) -> None:
|
||||
lenses = skill_text.split("## Review Lenses", 1)[1].split("## Procedure", 1)[0]
|
||||
# Round derivation must key off history the reviewer actually sees.
|
||||
assert "`changes_requested`" in lenses
|
||||
assert "Prior attempts on this task" in lenses
|
||||
# One distinct lens per round.
|
||||
for lens in ("Artifact", "Execution", "Contract"):
|
||||
assert lens in lenses
|
||||
# Execution lens must direct empirical verification via the terminal.
|
||||
assert "`terminal`" in lenses
|
||||
# Fan-out note: parallel reviewers get different briefs.
|
||||
assert "`delegate_task`" in lenses
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for optional-skills/devops/setup-wizard-generator (template integrity)."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "devops"
|
||||
/ "setup-wizard-generator"
|
||||
)
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
TEMPLATE = SKILL_DIR / "templates" / "template.sh"
|
||||
|
||||
|
||||
class TestFrontmatter:
|
||||
def _fm(self):
|
||||
text = SKILL_MD.read_text(encoding="utf-8")
|
||||
m = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
|
||||
assert m, "SKILL.md missing YAML frontmatter"
|
||||
return yaml.safe_load(m.group(1))
|
||||
|
||||
def test_name_matches_directory(self):
|
||||
assert self._fm()["name"] == "setup-wizard-generator"
|
||||
|
||||
def test_description_length(self):
|
||||
assert len(self._fm()["description"]) <= 60
|
||||
|
||||
def test_license_and_platforms(self):
|
||||
fm = self._fm()
|
||||
assert fm["license"] == "MIT"
|
||||
assert "linux" in fm["platforms"]
|
||||
|
||||
|
||||
class TestTemplate:
|
||||
def test_template_exists(self):
|
||||
assert TEMPLATE.is_file()
|
||||
|
||||
def test_bash_syntax(self):
|
||||
proc = subprocess.run(
|
||||
["bash", "-n", str(TEMPLATE)], capture_output=True, text=True
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
def test_stages_marker_present(self):
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
assert "STAGES" in text, "authoring marker missing"
|
||||
|
||||
def test_library_helpers_defined(self):
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
for helper in (
|
||||
"stage()",
|
||||
"say()",
|
||||
"step()",
|
||||
"open_url()",
|
||||
"write_env()",
|
||||
"set_secret()",
|
||||
"finish()",
|
||||
):
|
||||
assert helper in text, f"missing library helper {helper}"
|
||||
|
||||
def test_ask_secret_defined(self):
|
||||
# secret entry must exist (hidden input path)
|
||||
assert "ask_secret" in TEMPLATE.read_text(encoding="utf-8")
|
||||
|
||||
def test_total_stages_variable(self):
|
||||
assert re.search(
|
||||
r"^TOTAL_STAGES=", TEMPLATE.read_text(encoding="utf-8"), re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
class TestSkillBody:
|
||||
def test_references_template_path(self):
|
||||
assert "templates/template.sh" in SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
def test_no_upstream_harness_residue(self):
|
||||
text = SKILL_MD.read_text(encoding="utf-8").lower()
|
||||
for token in ("claude", "/wizard", "slash command"):
|
||||
assert token not in text, f"upstream residue: {token}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for the social-media-content-calendar optional skill."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "creative"
|
||||
/ "social-media-content-calendar"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "social-media-content-calendar"
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent")
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
repo_root = SKILL_PATH.parents[3]
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(repo_root.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(repo_root.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_no_phantom_skill_references():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "image-generation-workflow" not in content, "phantom skill ref must be gone"
|
||||
|
||||
|
||||
def test_honest_handoff_language():
|
||||
_, body = _frontmatter_and_body()
|
||||
assert "handed-off, not published" in body or "handed-off slots" in body, (
|
||||
"platforms without connectors must end at handoff, not claimed publication"
|
||||
)
|
||||
|
||||
|
||||
def test_body_structure_and_size():
|
||||
_, body = _frontmatter_and_body()
|
||||
for section in ("## When to Use", "## Procedure", "## Pitfalls", "## Verification"):
|
||||
assert section in body, f"missing section: {section}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 6
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
@@ -0,0 +1,134 @@
|
||||
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_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_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,113 @@
|
||||
"""Tests for the tldraw-offline optional skill.
|
||||
|
||||
Structural + internal-consistency checks only (stdlib + pytest, no network).
|
||||
The skill's runtime claims were validated live against the real tldraw offline
|
||||
app (headless) and its bundled script-context.d.ts; scripts/validate_shapes.mjs
|
||||
re-checks the shape schema against the tldraw SDK.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_DIR = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "optional-skills"
|
||||
/ "creative"
|
||||
/ "tldraw-offline"
|
||||
)
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
MAIN_JS = SKILL_DIR / "scripts" / "main.js"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def skill_text() -> str:
|
||||
return SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_js() -> str:
|
||||
return MAIN_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_MD.is_file(), f"missing {SKILL_MD}"
|
||||
|
||||
|
||||
def test_frontmatter_present(skill_text: str):
|
||||
assert skill_text.startswith("---\n"), "SKILL.md must open with YAML frontmatter"
|
||||
assert skill_text.count("---") >= 2, "frontmatter must be delimited by two '---'"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_required_sections_present(skill_text: str):
|
||||
for heading in (
|
||||
"## When to Use",
|
||||
"## Prerequisites",
|
||||
"## How to Run",
|
||||
"## Quick Reference",
|
||||
"## Procedure",
|
||||
"## Pitfalls",
|
||||
"## Verification",
|
||||
):
|
||||
assert heading in skill_text, f"missing section: {heading}"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_counter_example_is_interactive_and_safe():
|
||||
"""The counter.js example must show the verified interactive-UI pattern:
|
||||
ctx contract, pointer_down handling, and REQUIRED signal-based cleanup
|
||||
(whose absence causes the double-fire bug found live)."""
|
||||
counter = (SKILL_DIR / "scripts" / "counter.js").read_text(encoding="utf-8")
|
||||
assert "export default function ({ editor, helpers, signal })" in counter
|
||||
assert "pointer_down" in counter
|
||||
assert "editor.on('event'" in counter
|
||||
# the cleanup that prevents the click-doubling leak
|
||||
assert "signal.addEventListener('abort'" in counter
|
||||
assert "editor.off('event'" in counter
|
||||
# state kept in meta, rendered as label
|
||||
assert "meta" in counter and "count" in counter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_uses_richtext_not_bare_string(skill_text: str):
|
||||
assert "toRichText" in skill_text
|
||||
assert "richText" in skill_text
|
||||
|
||||
|
||||
|
||||
|
||||
def test_main_js_matches_verified_contract(main_js: str):
|
||||
# main.js must use the real contract learned from the running app.
|
||||
assert "export default function ({ editor, helpers, signal })" in main_js
|
||||
# primitives imported from tldraw, not used as globals
|
||||
assert "from 'tldraw'" in main_js
|
||||
assert "createShapeId" in main_js and "toRichText" in main_js
|
||||
# idempotent furniture
|
||||
assert "createShapeIfMissing" in main_js
|
||||
# batched writes
|
||||
assert "editor.run(" in main_js
|
||||
# reactive + REQUIRED signal cleanup
|
||||
assert "editor.store.listen" in main_js
|
||||
assert "signal.addEventListener('abort'" in main_js
|
||||
# script-owned writes kept out of undo
|
||||
assert "history: 'ignore'" in main_js
|
||||
|
||||
|
||||
|
||||
|
||||
def test_platforms_declared(skill_text: str):
|
||||
m = re.search(r"^platforms: (.*)$", skill_text, re.MULTILINE)
|
||||
assert m, "platforms field required (cross-platform desktop app)"
|
||||
for os_name in ("linux", "macos", "windows"):
|
||||
assert os_name in m.group(1)
|
||||
@@ -0,0 +1,772 @@
|
||||
"""Hermetic tests for the unbroker skill.
|
||||
|
||||
Stdlib + pytest only; NO live network, NO browser, NO email. Each test runs against
|
||||
an isolated temp PDD_DATA_DIR. Runnable with pytest or directly:
|
||||
|
||||
python3 -m pytest tests/test_unbroker_skill.py -q
|
||||
python3 tests/test_unbroker_skill.py # portable fallback runner
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Resolve the skill's scripts dir across layouts: standalone dev repo (tests/) and hermes-agent
|
||||
# (tests/skills/ -> optional-skills/security/unbroker/scripts).
|
||||
_HERE = Path(__file__).resolve()
|
||||
_REL = ("optional-skills", "security", "unbroker", "scripts")
|
||||
_CANDIDATES = [
|
||||
_HERE.parent.parent / "skill" / "scripts", # standalone dev repo
|
||||
_HERE.parent.parent.joinpath(*_REL), # standalone layout
|
||||
_HERE.parent.parent.parent.joinpath(*_REL), # hermes-agent (tests/skills/)
|
||||
]
|
||||
SCRIPTS = next((c for c in _CANDIDATES if (c / "pdd.py").exists()), _CANDIDATES[0])
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import autopilot # noqa: E402
|
||||
import contextlib as _ctx # noqa: E402
|
||||
import io as _io # noqa: E402
|
||||
import json as _json # noqa: E402
|
||||
import smtplib as _smtplib # noqa: E402
|
||||
import time as _time # noqa: E402
|
||||
|
||||
import badbool # noqa: E402
|
||||
import brokers # noqa: E402
|
||||
import cdp # noqa: E402
|
||||
import config # noqa: E402
|
||||
import crypto # noqa: E402
|
||||
import dossier # noqa: E402
|
||||
import email_modes # noqa: E402
|
||||
import emailer # noqa: E402
|
||||
import pdd # noqa: E402
|
||||
import legal # noqa: E402
|
||||
import ledger # noqa: E402
|
||||
import paths # noqa: E402
|
||||
import registry # noqa: E402
|
||||
import report # noqa: E402
|
||||
import storage # noqa: E402
|
||||
import tiers # noqa: E402
|
||||
import vectors # noqa: E402
|
||||
|
||||
_AGE = bool(shutil.which("age") and shutil.which("age-keygen"))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def temp_env():
|
||||
"""Isolate every test in a fresh PDD_DATA_DIR."""
|
||||
prev = os.environ.get("PDD_DATA_DIR")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.environ["PDD_DATA_DIR"] = str(Path(d) / "pdd")
|
||||
try:
|
||||
yield Path(os.environ["PDD_DATA_DIR"])
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("PDD_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["PDD_DATA_DIR"] = prev
|
||||
|
||||
|
||||
def _consenting(full_name="Jane Q. Public"):
|
||||
return {
|
||||
"subject_id": "sub_test01",
|
||||
"consent": {"authorized": True, "method": "self"},
|
||||
"identity": {
|
||||
"full_name": full_name,
|
||||
"emails": ["jane@example.com"],
|
||||
"phones": ["+1-415-555-0137"],
|
||||
"date_of_birth": "1987-04-12",
|
||||
"current_address": {"city": "Oakland", "state": "CA", "postal": "94601"},
|
||||
},
|
||||
"preferences": {"email_mode": "draft_only"},
|
||||
}
|
||||
|
||||
|
||||
# --- config -------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_browser_clears_captcha_logic():
|
||||
assert config.browser_clears_captcha({"browser_backend": "browserbase"}) is True
|
||||
assert config.browser_clears_captcha({"browser_backend": "agent-browser"}) is False
|
||||
assert config.browser_clears_captcha({"browser_backend": "auto"}, env={}) is False
|
||||
assert config.browser_clears_captcha({"browser_backend": "auto"}, env={"BROWSERBASE_API_KEY": "x"}) is True
|
||||
|
||||
|
||||
# --- storage ------------------------------------------------------------------
|
||||
|
||||
def test_storage_json_and_jsonl_roundtrip():
|
||||
with temp_env() as data:
|
||||
p = data / "x.json"
|
||||
storage.write_json(p, {"a": 1})
|
||||
assert storage.read_json(p) == {"a": 1}
|
||||
assert storage.read_json(data / "missing.json", []) == []
|
||||
log = data / "audit.jsonl"
|
||||
storage.append_jsonl(log, {"e": 1})
|
||||
storage.append_jsonl(log, {"e": 2})
|
||||
assert [r["e"] for r in storage.read_jsonl(log)] == [1, 2]
|
||||
|
||||
|
||||
# --- at-rest encryption -------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- broker DB ----------------------------------------------------------------
|
||||
|
||||
def test_seed_broker_db_loads_and_is_well_formed():
|
||||
everyone = brokers.load_all()
|
||||
assert len(everyone) >= 10
|
||||
ids = {b["id"] for b in everyone}
|
||||
assert {"spokeo", "whitepages", "mylife"} <= ids
|
||||
for b in everyone:
|
||||
assert b.get("id") and b.get("name") and b.get("priority") in {"crucial", "high", "standard", "long_tail"}
|
||||
assert (b.get("optout") or {}).get("method")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_blocked_pass_records_and_cluster_coverage():
|
||||
# Records added from the blocked-tail pass load, resolve, and dedupe correctly.
|
||||
ids = {b["id"] for b in brokers.load_all()}
|
||||
assert {"addresses", "socialcatfish"} <= ids
|
||||
# addresses.com is a PeopleConnect/Intelius front-end -> covered by the intelius cluster (deduped).
|
||||
assert "addresses" in brokers.clusters().get("intelius", [])
|
||||
for bid in ("addresses", "socialcatfish"):
|
||||
b = brokers.get(bid)
|
||||
assert tiers.select_tier(b) in {"T0", "T1", "T2", "T3"}
|
||||
assert b["optout"]["method"]
|
||||
|
||||
|
||||
# --- tier selection -----------------------------------------------------------
|
||||
|
||||
def test_every_broker_resolves_to_valid_tier():
|
||||
for b in brokers.load_all():
|
||||
assert tiers.select_tier(b) in {"T0", "T1", "T2", "T3"}
|
||||
|
||||
|
||||
|
||||
|
||||
def test_captcha_tier_shifts_with_browser():
|
||||
tps = brokers.get("truepeoplesearch")
|
||||
assert tiers.select_tier(tps, "programmatic", browser_clears_captcha=False) == "T2"
|
||||
assert tiers.select_tier(tps, "programmatic", browser_clears_captcha=True) == "T1"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_plan_excludes_disallowed_fields():
|
||||
d = _consenting()
|
||||
actions = tiers.plan(d, brokers.load_all(), config.DEFAULT_CONFIG)
|
||||
for a in actions:
|
||||
assert "ssn" not in a["disclosure_fields"]
|
||||
assert "profile_url" not in a["disclosure_fields"]
|
||||
|
||||
|
||||
|
||||
|
||||
def _mini_broker(bid, owns=None, requires=None, notes="", quirks=None):
|
||||
return {"id": bid, "name": bid.title(), "priority": "high",
|
||||
"search": {"by": ["name"]},
|
||||
"optout": {"method": "web_form", "url": f"https://{bid}.example/optout",
|
||||
"requires": requires or {}, "inputs": ["full_name"], "owns": owns or [],
|
||||
"notes": notes, "quirks": quirks or []},
|
||||
"owns": owns or []}
|
||||
|
||||
|
||||
def test_batch_plan_groups_by_ledger_state():
|
||||
d = _consenting()
|
||||
bl = [_mini_broker("aaa"), _mini_broker("bbb"), _mini_broker("ccc"), _mini_broker("ddd")]
|
||||
ledger = {
|
||||
"aaa": {"state": "found"},
|
||||
"bbb": {"state": "not_found"},
|
||||
"ccc": {"state": "blocked"},
|
||||
# ddd absent -> unscanned/new
|
||||
}
|
||||
bp = tiers.batch_plan(d, bl, config.DEFAULT_CONFIG, ledger)
|
||||
assert bp["phase"] == "discover" # ddd is unscanned
|
||||
assert bp["counts"]["found"] == 1
|
||||
assert bp["counts"]["not_found"] == 1
|
||||
assert bp["counts"]["blocked"] == 1
|
||||
assert bp["counts"]["unscanned"] == 1
|
||||
assert any("PHASE 1" in t for t in bp["next_actions"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- ledger / state machine ---------------------------------------------------
|
||||
|
||||
def test_ledger_valid_transition_and_audit():
|
||||
with temp_env():
|
||||
sid = "sub_test01"
|
||||
ledger.transition(sid, "spokeo", "searching")
|
||||
case = ledger.transition(sid, "spokeo", "found", found=True)
|
||||
assert case["state"] == "found" and case["found"] is True
|
||||
# found -> submitted must be allowed directly (action_selected is optional)
|
||||
case = ledger.transition(sid, "spokeo", "submitted")
|
||||
assert case["state"] == "submitted"
|
||||
audit = storage.read_jsonl(__import__("paths").audit_path(sid))
|
||||
assert any(e["to"] == "found" for e in audit)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_indirect_exposure_state_and_transitions():
|
||||
with temp_env():
|
||||
sid = "sub_test01"
|
||||
# a scan can land directly on indirect_exposure (PII on a relative's record)
|
||||
case = ledger.transition(sid, "thatsthem", "indirect_exposure",
|
||||
evidence={"summary": "email on relative record"})
|
||||
assert case["state"] == "indirect_exposure"
|
||||
# the lever from there is a targeted delete-my-PII request (-> submitted)
|
||||
assert ledger.transition(sid, "thatsthem", "submitted")["state"] == "submitted"
|
||||
# and a separate broker: not_found -> indirect_exposure is allowed (found on re-read)
|
||||
ledger.transition(sid, "radaris", "not_found")
|
||||
assert ledger.transition(sid, "radaris", "indirect_exposure")["state"] == "indirect_exposure"
|
||||
# re-scan can clear it
|
||||
assert ledger.transition(sid, "radaris", "not_found")["state"] == "not_found"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- dossier / consent / least-disclosure ------------------------------------
|
||||
|
||||
|
||||
|
||||
def test_least_disclosure_selection():
|
||||
d = _consenting()
|
||||
got = dossier.select_disclosure(d, ["full_name", "contact_email", "profile_url", "ssn", "date_of_birth"])
|
||||
assert set(got) == {"full_name", "contact_email", "date_of_birth"}
|
||||
assert "ssn" not in got and "profile_url" not in got
|
||||
|
||||
|
||||
|
||||
|
||||
# --- alternates / search vectors ---------------------------------------------
|
||||
|
||||
def test_all_names_and_locations_dedupe():
|
||||
d = _consenting()
|
||||
d["identity"]["also_known_as"] = ["Jane Public", "Jane Q. Public"] # 2nd dups primary
|
||||
d["identity"]["prior_addresses"] = [{"city": "Berkeley", "state": "CA"}, {"city": "Oakland", "state": "CA"}]
|
||||
assert dossier.all_names(d) == ["Jane Q. Public", "Jane Public"]
|
||||
assert [loc["city"] for loc in dossier.all_locations(d)] == ["Oakland", "Berkeley"] # current first, deduped
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- opaque ids / fan-out / antibot ------------------------------------------
|
||||
|
||||
def test_subject_id_is_opaque_no_name_leak():
|
||||
sid = dossier.new_subject_id("Maiden Married Person")
|
||||
assert sid.startswith("sub_")
|
||||
assert "maiden" not in sid.lower() and "person" not in sid.lower()
|
||||
assert dossier.new_subject_id("Maiden Married Person") != sid # not derived from the name
|
||||
|
||||
|
||||
def test_fanout_batches_large_runs():
|
||||
g = tiers.fanout([{"id": f"b{i}"} for i in range(20)], batch_size=8)
|
||||
assert g["broker_count"] == 20 and g["should_fanout"] is True
|
||||
assert len(g["batches"]) == 3 and g["batches"][0] == [f"b{i}" for i in range(8)]
|
||||
small = tiers.fanout([{"id": "x"}, {"id": "y"}], batch_size=8)
|
||||
assert small["should_fanout"] is False and small["batches"] == [["x", "y"]]
|
||||
|
||||
|
||||
|
||||
|
||||
# --- cdp (operator browser over the DevTools protocol) --------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- legal / templates --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def test_render_optout_email_includes_listing_and_name():
|
||||
b = brokers.get("spokeo")
|
||||
out = legal.render_optout_email(b, {"full_name": "Jane Q. Public",
|
||||
"contact_email": "jane@example.com",
|
||||
"listing_urls": ["https://www.spokeo.com/jane"]})
|
||||
assert "Jane Q. Public" in out and "https://www.spokeo.com/jane" in out
|
||||
|
||||
|
||||
|
||||
|
||||
# --- email verification-link extraction --------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- BADBOOL live-pull parser -------------------------------------------------
|
||||
|
||||
BADBOOL_FIXTURE = """
|
||||
## Search Engines
|
||||
### Google
|
||||
This is not a broker; ignore it.
|
||||
|
||||
## People Search Sites
|
||||
|
||||
### \U0001F490 BeenVerified
|
||||
Find your information and opt out of [people search](https://www.beenverified.com/app/optout/search).
|
||||
|
||||
### \U0001F490 \U0001F4DE MyLife
|
||||
[Find your information](https://www.mylife.com), and then [opt out](https://www.mylife.com/privacyrequest).
|
||||
|
||||
### \U0001F3AB PimEyes
|
||||
To opt out, [upload an ID](https://pimeyes.com/en/opt-out-request-form).
|
||||
|
||||
## Special Circumstances
|
||||
### Not A Broker
|
||||
Ignore this section entirely.
|
||||
"""
|
||||
|
||||
|
||||
def test_badbool_parses_people_search_section_only():
|
||||
recs = badbool.parse(BADBOOL_FIXTURE)
|
||||
ids = {r["id"] for r in recs}
|
||||
assert ids == {"beenverified", "mylife", "pimeyes"} # google + notabroker excluded
|
||||
bv = next(r for r in recs if r["id"] == "beenverified")
|
||||
assert bv["priority"] == "crucial"
|
||||
assert "beenverified.com/app/optout" in (bv["optout"]["url"] or "")
|
||||
assert bv["source"] == "BADBOOL-auto" and bv["confidence"] == "auto"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_badbool_merge_keeps_curated_and_adds_new():
|
||||
with temp_env():
|
||||
badbool.refresh(__import__("paths").brokers_cache_path(), markdown=BADBOOL_FIXTURE)
|
||||
merged = {b["id"]: b for b in brokers.load_all()}
|
||||
# curated record wins over the live one
|
||||
assert merged["beenverified"]["source"] == "BADBOOL"
|
||||
# a non-curated live record is added with auto confidence
|
||||
assert "pimeyes" in merged and merged["pimeyes"]["confidence"] == "auto"
|
||||
|
||||
|
||||
# --- report -------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# --- autonomy: auto-configure ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def test_auto_configure_picks_most_autonomous():
|
||||
with temp_env():
|
||||
# bare env -> draft_only floor, auto browser (still fully hands-off policy-wise)
|
||||
cfg = config.auto_configure(env={})
|
||||
assert cfg["autonomy"] == "full"
|
||||
assert cfg["email_mode"] == "draft_only"
|
||||
assert cfg["browser_backend"] == "auto"
|
||||
# SMTP creds -> programmatic email; Browserbase key -> cloud browser
|
||||
cfg = config.auto_configure(env={"EMAIL_ADDRESS": "agent@gmail.com",
|
||||
"EMAIL_PASSWORD": "app-pass",
|
||||
"BROWSERBASE_API_KEY": "bb"})
|
||||
assert cfg["email_mode"] == "programmatic"
|
||||
assert cfg["browser_backend"] == "browserbase"
|
||||
# AgentMail only -> alias mode
|
||||
assert config.auto_configure(env={"AGENTMAIL_API_KEY": "am"})["email_mode"] == "alias"
|
||||
# encryption auto-on exactly when age is installed (free privacy, zero human cost)
|
||||
assert config.auto_configure(env={})["encryption"] == ("age" if _AGE else "none")
|
||||
|
||||
|
||||
# --- emailer: programmatic send + verification polling --------------------------------------
|
||||
|
||||
|
||||
|
||||
class _FakeSMTP:
|
||||
sent: list = []
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host, self.port = host, port
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def ehlo(self):
|
||||
pass
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, user, password):
|
||||
self.user = user
|
||||
|
||||
def send_message(self, msg):
|
||||
_FakeSMTP.sent.append(msg)
|
||||
|
||||
|
||||
def test_emailer_send_locks_recipient_to_broker():
|
||||
env = {"EMAIL_ADDRESS": "agent@gmail.com", "EMAIL_PASSWORD": "p"}
|
||||
broker = {"id": "radaris", "optout": {"email": "privacy@radaris.example"}}
|
||||
_FakeSMTP.sent = []
|
||||
out = emailer.send(broker, "Subject: Remove my listing\n\nBody here", env=env,
|
||||
_smtp_factory=_FakeSMTP)
|
||||
assert out["to"] == "privacy@radaris.example"
|
||||
assert _FakeSMTP.sent[0]["Subject"] == "Remove my listing"
|
||||
assert "Body here" in _FakeSMTP.sent[0].get_content()
|
||||
# arbitrary recipients are refused -- this tool cannot be repurposed to email people
|
||||
try:
|
||||
emailer.send(broker, "Subject: x\n\nb", to="victim@example.com", env=env,
|
||||
_smtp_factory=_FakeSMTP)
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("non-broker recipient must be refused")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_browser_send_payload_is_recipient_locked():
|
||||
broker = {"id": "radaris", "optout": {"email": "privacy@radaris.example"}}
|
||||
p = emailer.browser_send_payload(broker, "Subject: Remove my listing\n\nBody here")
|
||||
assert p["to"] == "privacy@radaris.example"
|
||||
assert p["subject"] == "Remove my listing" and "Body here" in p["body"]
|
||||
# the browser lane refuses arbitrary recipients too (same guard as SMTP send)
|
||||
try:
|
||||
emailer.browser_send_payload(broker, "Subject: x\n\nb", to="victim@example.com")
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("browser lane must refuse a non-broker recipient")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_verification_link_from_messages_is_domain_scoped():
|
||||
broker = {"id": "spokeo", "name": "Spokeo",
|
||||
"search": {"url": "https://www.spokeo.com/"},
|
||||
"optout": {"url": "https://www.spokeo.com/optout"}}
|
||||
phish = {"from": "phisher@evil.example", "subject": "verify now",
|
||||
"text": "click https://evil.example/optout/verify?x=1"}
|
||||
real = {"from": "no-reply@spokeo.com", "subject": "Confirm your opt out",
|
||||
"text": "Confirm here: https://www.spokeo.com/optout/verify/abc123"}
|
||||
hit = emailer.link_from_messages([phish, real], broker)
|
||||
assert hit["link"] == "https://www.spokeo.com/optout/verify/abc123"
|
||||
# a phishing-only inbox yields nothing (domain scoping + link scoring)
|
||||
assert emailer.link_from_messages([phish], broker) is None
|
||||
|
||||
|
||||
# --- ledger: follow-up scheduling + due queue ------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- autopilot: the autonomous action queue --------------------------------------------------
|
||||
|
||||
def _auto_cfg(**over):
|
||||
cfg = dict(config.DEFAULT_CONFIG)
|
||||
cfg.update(over)
|
||||
return cfg
|
||||
|
||||
|
||||
def test_next_actions_scan_first_then_optouts_parents_first():
|
||||
with temp_env():
|
||||
d = _consenting()
|
||||
bl = [_mini_broker("parent", owns=["kid"]), _mini_broker("kid"), _mini_broker("solo")]
|
||||
q = autopilot.next_actions(d, bl, _auto_cfg(), {}, env={})
|
||||
types = [a["type"] for a in q["actions"]]
|
||||
assert "scan_inline" in types
|
||||
assert not any(t.startswith("optout") for t in types) # never act before the crawl
|
||||
assert q["phase"] == "discover"
|
||||
led = {"parent": {"state": "found"}, "kid": {"state": "found"}, "solo": {"state": "found"}}
|
||||
q2 = autopilot.next_actions(d, bl, _auto_cfg(), led, env={})
|
||||
opt = [a for a in q2["actions"] if a["type"] == "optout_web_form"]
|
||||
assert [a["broker_id"] for a in opt] == ["parent", "solo"] # kid covered by parent
|
||||
assert q2["phase"] == "delete"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_next_actions_blocked_stealth_or_operator_browser():
|
||||
with temp_env():
|
||||
d = _consenting()
|
||||
b = _mini_broker("gated")
|
||||
led = {"gated": {"state": "blocked"}}
|
||||
q = autopilot.next_actions(d, [b], _auto_cfg(), led, env={"BROWSERBASE_API_KEY": "bb"})
|
||||
assert any(a["type"] == "stealth_rescan" for a in q["actions"])
|
||||
q2 = autopilot.next_actions(d, [b], _auto_cfg(), led, env={})
|
||||
assert any("anti-bot" in t["reason"] for t in q2["human_digest"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_parked_and_reappeared_states_group_correctly():
|
||||
# Regression: human_task_queued / action_selected / reappeared used to fall into "unscanned",
|
||||
# so the autonomous loop would try to re-scan parked or already-actioned cases forever.
|
||||
with temp_env():
|
||||
d = _consenting()
|
||||
bl = [_mini_broker("parked"), _mini_broker("chosen"), _mini_broker("back")]
|
||||
led = {"parked": {"state": "human_task_queued"},
|
||||
"chosen": {"state": "action_selected"},
|
||||
"back": {"state": "reappeared"}}
|
||||
bp = tiers.batch_plan(d, bl, config.DEFAULT_CONFIG, led)
|
||||
assert bp["counts"]["unscanned"] == 0
|
||||
assert bp["phase"] == "delete"
|
||||
assert [r["broker_id"] for r in bp["groups"]["human"]] == ["parked"]
|
||||
assert {r["broker_id"] for r in bp["groups"]["found"]} == {"chosen", "back"}
|
||||
q = autopilot.next_actions(d, bl, _auto_cfg(), led, env={})
|
||||
assert not any(a["type"] in ("scan_inline", "fanout_scan") for a in q["actions"])
|
||||
assert {a["broker_id"] for a in q["actions"] if a["type"] == "optout_web_form"} == {"chosen", "back"}
|
||||
|
||||
|
||||
# --- cluster parents: verified deletion lanes + data-driven playbooks ------------------------
|
||||
|
||||
|
||||
|
||||
def test_curated_intelius_suppress_first_not_delete():
|
||||
# PeopleConnect is the EXCEPTION to deletion-beats-suppression: deleting user data wipes
|
||||
# your suppressions and does not stop public-records re-listing, so suppress-and-maintain.
|
||||
b = brokers.get("intelius")
|
||||
d = b["optout"]["deletion"]
|
||||
assert d["prefer"] is False and d["via"] == "in_flow"
|
||||
assert d["email"] == "privacy@peopleconnect.us" # rights-request address for the data-purge path
|
||||
steps = " ".join(b["optout"]["playbook"]).upper()
|
||||
assert "SUPPRESS" in steps # the recommended action
|
||||
assert "DELETE MY USER DATA" in steps # names the trap to avoid
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_request_kind_is_residency_honest():
|
||||
ca = {"residency_jurisdiction": "US-CA"}
|
||||
tx = {"residency_jurisdiction": "US-TX"}
|
||||
de = {"residency_jurisdiction": "EU-DE"}
|
||||
assert autopilot.request_kind(ca) == "ccpa"
|
||||
assert autopilot.request_kind(tx) == "generic" # never claim CCPA for a non-CA resident
|
||||
assert autopilot.request_kind(de) == "gdpr"
|
||||
assert autopilot.request_kind({}) == "generic"
|
||||
# broker restriction can force DOWN to generic but never upgrade
|
||||
assert autopilot.request_kind(tx, allowed=["ccpa", "generic"]) == "generic"
|
||||
assert autopilot.request_kind(ca, allowed=["generic"]) == "generic"
|
||||
assert autopilot.request_kind(ca, allowed=["ccpa", "generic"]) == "ccpa"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- human-task digest ------------------------------------------------------------------------
|
||||
|
||||
def test_human_tasks_digest_markdown():
|
||||
with temp_env():
|
||||
sid = "sub_test01"
|
||||
ledger.transition(sid, "mylife", "found", found=True)
|
||||
ledger.transition(sid, "mylife", "human_task_queued",
|
||||
human_task_reason="gov ID demanded")
|
||||
ledger.transition(sid, "fastpeoplesearch", "blocked")
|
||||
md = report.human_tasks_markdown(sid)
|
||||
assert "gov ID demanded" in md
|
||||
assert "Withhold" in md
|
||||
assert "fastpeoplesearch" in md.lower()
|
||||
# empty ledger -> explicitly says nothing is needed
|
||||
assert "Nothing needs a human" in report.human_tasks_markdown("sub_other")
|
||||
|
||||
|
||||
# --- CA data broker registry (coverage breadth: DROP + email lane) ---------------------------
|
||||
|
||||
def _registry_csv():
|
||||
"""Mimic the CA registry CSV: junk row 0, label row 1 (with the real NBSP), data rows."""
|
||||
import csv as _csv
|
||||
import io as _io
|
||||
buf = _io.StringIO()
|
||||
w = _csv.writer(buf)
|
||||
w.writerow(["", "junk header the site hides", "", "", "", ""])
|
||||
w.writerow(["Data broker\xa0name:", "Doing Business As (DBA), if applicable:",
|
||||
"Data broker primary website:", "Data broker primary contact email address:",
|
||||
"Data broker's primary website that contains details on how consumers can exercise "
|
||||
"their CA Consumer Privacy Act rights, including how to delete their personal information:",
|
||||
"The data broker or any of its subsidiaries is regulated by the federal Fair Credit "
|
||||
"Reporting Act (FCRA):"])
|
||||
w.writerow(["Acme Data LLC", "AcmeDBA", "https://acme.example",
|
||||
"privacy@acme.example", "https://acme.example/ccpa", "No"])
|
||||
w.writerow(["Credit Bureau Co", "", "https://cbc.example",
|
||||
"privacy@cbc.example", "https://cbc.example/rights", "Yes"])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- hardening: locking / rate-limit / retry / idempotency / freshness / metrics ------------
|
||||
|
||||
def test_storage_lock_mutual_exclusion_and_stale_break():
|
||||
with temp_env() as data:
|
||||
target = data / "x.json"
|
||||
with storage.locked(target): # hold the lock
|
||||
try:
|
||||
with storage.locked(target, timeout=0.2): # second acquire must time out
|
||||
raise AssertionError("second acquire should have timed out")
|
||||
except TimeoutError:
|
||||
pass
|
||||
with storage.locked(target, timeout=0.2): # released -> acquires fine
|
||||
pass
|
||||
# a stale lock (old mtime) from a crashed writer gets broken
|
||||
lock = target.with_name(target.name + ".lock")
|
||||
lock.write_text("999999")
|
||||
old = _time.time() - 120
|
||||
os.utime(lock, (old, old))
|
||||
with storage.locked(target, timeout=0.2, stale=30):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
class _FlakySMTP:
|
||||
attempts = 0
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
_FlakySMTP.attempts += 1
|
||||
if _FlakySMTP.attempts < 3:
|
||||
raise _smtplib.SMTPServerDisconnected("transient")
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def ehlo(self):
|
||||
pass
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, u, p):
|
||||
pass
|
||||
|
||||
def send_message(self, m):
|
||||
_FlakySMTP.sent = m
|
||||
|
||||
|
||||
class _AuthFailSMTP(_FlakySMTP):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def login(self, u, p):
|
||||
raise _smtplib.SMTPAuthenticationError(535, b"bad creds")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _run(argv) -> dict:
|
||||
buf = _io.StringIO()
|
||||
with _ctx.redirect_stdout(buf):
|
||||
pdd.main(argv)
|
||||
return _json.loads(buf.getvalue())
|
||||
|
||||
|
||||
|
||||
|
||||
def test_show_reads_back_case_state_and_evidence():
|
||||
with temp_env():
|
||||
sid = _run(["intake", "--full-name", "Jane Q. Public",
|
||||
"--email", "jane@example.com", "--consent"])["subject_id"]
|
||||
_run(["record", sid, "radaris", "found", "--found", "true",
|
||||
"--evidence", '{"listing_urls": ["https://radaris.com/p/x"]}'])
|
||||
shown = _run(["show", sid, "radaris"])
|
||||
assert shown["broker"] == "radaris" and shown["state"] == "found"
|
||||
assert shown["found"] is True
|
||||
assert shown["evidence"].get("listing_urls") == ["https://radaris.com/p/x"]
|
||||
# Unknown case returns a fresh (new) case, not an error.
|
||||
empty = _run(["show", sid, "not_a_broker"])
|
||||
assert empty["state"] == "new" and empty["evidence"] == {}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_report_metrics_removal_rate_and_overdue():
|
||||
with temp_env():
|
||||
sid = "sub_test01"
|
||||
for st in ("found", "submitted", "awaiting_processing", "confirmed_removed"):
|
||||
ledger.transition(sid, "a", st, **({"found": True} if st == "found" else {}))
|
||||
ledger.transition(sid, "b", "found", found=True) # open
|
||||
for st in ("found", "submitted", "awaiting_processing"):
|
||||
ledger.transition(sid, "c", st, **({"found": True} if st == "found" else {}))
|
||||
led = ledger.load(sid)
|
||||
led["c"]["next_recheck_at"] = "2000-01-01T00:00:00Z" # force overdue
|
||||
ledger.save(sid, led)
|
||||
m = report.metrics(sid)
|
||||
assert m["confirmed_removed"] == 1
|
||||
assert m["open_needs_action"] >= 1 and m["in_flight_claimed"] >= 1
|
||||
assert m["overdue_rechecks"] >= 1 and 0 < m["removal_rate"] <= 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = []
|
||||
tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
|
||||
for name, fn in tests:
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append((name, exc))
|
||||
print(f"FAIL {name}: {exc!r}")
|
||||
print(f"\n{len(tests) - len(failures)}/{len(tests)} passed")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for the weekly-review-planning skill and blueprint->skill wiring."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL_PATH = (
|
||||
REPO_ROOT / "skills" / "productivity" / "weekly-review-planning" / "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_and_body():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
m = re.search(r"\n---\s*\n", content[3:])
|
||||
assert m, "frontmatter must close with ---"
|
||||
fm = yaml.safe_load(content[3 : m.start() + 3])
|
||||
body = content[m.end() + 3 :]
|
||||
return fm, body
|
||||
|
||||
|
||||
def test_skill_file_exists():
|
||||
assert SKILL_PATH.is_file()
|
||||
|
||||
|
||||
def test_frontmatter_required_fields():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for field in ("name", "description", "version", "author", "license", "platforms"):
|
||||
assert field in fm, f"missing frontmatter field: {field}"
|
||||
assert fm["name"] == "weekly-review-planning"
|
||||
|
||||
|
||||
def test_description_hardline():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
desc = fm["description"]
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars; hardline is 60"
|
||||
assert desc.endswith(".")
|
||||
|
||||
|
||||
def test_author_credits_human_first():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
assert not fm["author"].startswith("Hermes Agent")
|
||||
assert "benbarclay" in fm["author"]
|
||||
|
||||
|
||||
def test_related_skills_resolve_in_repo():
|
||||
fm, _ = _frontmatter_and_body()
|
||||
for name in fm["metadata"]["hermes"]["related_skills"]:
|
||||
hits = (
|
||||
list(REPO_ROOT.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(REPO_ROOT.glob(f"optional-skills/*/{name}/SKILL.md"))
|
||||
+ list(REPO_ROOT.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
assert hits, f"related_skills entry does not resolve in-repo: {name}"
|
||||
|
||||
|
||||
def test_body_structure_and_size():
|
||||
_, body = _frontmatter_and_body()
|
||||
for section in ("## When to Use", "## Procedure", "## Pitfalls", "## Verification"):
|
||||
assert section in body, f"missing section: {section}"
|
||||
assert len(SKILL_PATH.read_text(encoding="utf-8")) <= 100_000
|
||||
|
||||
|
||||
def test_no_machine_local_paths():
|
||||
content = SKILL_PATH.read_text(encoding="utf-8")
|
||||
assert "/home/" not in content
|
||||
|
||||
|
||||
def test_steps_have_completion_criteria():
|
||||
_, body = _frontmatter_and_body()
|
||||
steps = re.findall(r"^### \d+\..*?(?=^### \d+\.|^## )", body, re.MULTILINE | re.DOTALL)
|
||||
assert len(steps) >= 6
|
||||
for step in steps:
|
||||
assert "Done when" in step, f"step missing completion criterion: {step[:60]!r}"
|
||||
|
||||
|
||||
def _skill_dir_exists(name: str) -> bool:
|
||||
return bool(
|
||||
list(REPO_ROOT.glob(f"skills/*/{name}/SKILL.md"))
|
||||
+ list(REPO_ROOT.glob(f"skills/*/*/{name}/SKILL.md"))
|
||||
)
|
||||
|
||||
|
||||
def test_blueprint_loads_this_skill():
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
bp = next(b for b in CATALOG if b.key == "weekly-review")
|
||||
assert "weekly-review-planning" in bp.skills
|
||||
assert "weekly-review-planning" in bp.prompt_template
|
||||
|
||||
|
||||
def test_every_blueprint_skill_resolves_in_repo():
|
||||
"""Invariant: any skill a blueprint loads must exist as a bundled skill."""
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
for bp in CATALOG:
|
||||
for skill_name in bp.skills:
|
||||
assert _skill_dir_exists(skill_name), (
|
||||
f"blueprint {bp.key!r} loads nonexistent skill {skill_name!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_task_skill_blueprints_are_wired():
|
||||
"""The recurring-task blueprints must load their procedure skills."""
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
expected = {
|
||||
"morning-brief": "google-workspace",
|
||||
"important-mail": "email-inbox-triage",
|
||||
"weekly-review": "weekly-review-planning",
|
||||
"price-watch": "product-price-monitor",
|
||||
}
|
||||
by_key = {b.key: b for b in CATALOG}
|
||||
for key, skill_name in expected.items():
|
||||
assert key in by_key, f"blueprint {key!r} missing from catalog"
|
||||
assert skill_name in by_key[key].skills, (
|
||||
f"blueprint {key!r} must load skill {skill_name!r}"
|
||||
)
|
||||
@@ -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,87 @@
|
||||
"""Behavioral contract for xurl / x_search routing guidance.
|
||||
|
||||
These tests assert structural invariants (required topics + placement of the
|
||||
routing guidance), not frozen prose snapshots.
|
||||
|
||||
Placement contract (July 2026):
|
||||
- The xurl SKILL must NOT name `x_search` (or any other credential-gated
|
||||
surface): the skill loads even when that tool isn't registered, so it must
|
||||
describe its own search distinctively in its own terms (raw, engageable
|
||||
post objects as the authenticated account).
|
||||
- Cross-surface routing guidance lives where both surfaces are known to
|
||||
exist together: the x_search feature docs, the toolset description, and
|
||||
the tools-config setup note.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
XURL_SKILL = REPO_ROOT / "skills" / "social-media" / "xurl" / "SKILL.md"
|
||||
X_SEARCH_DOC = REPO_ROOT / "website" / "docs" / "user-guide" / "features" / "x-search.md"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _contains_any(text: str, *needles: str) -> bool:
|
||||
lowered = text.lower()
|
||||
return any(n.lower() in lowered for n in needles)
|
||||
|
||||
|
||||
def test_xurl_skill_never_names_credential_gated_surfaces():
|
||||
"""The skill must be self-contained: no cross-references to tools that
|
||||
may not be registered (x_search is check_fn-gated on xAI credentials)."""
|
||||
lowered = _read(XURL_SKILL).lower()
|
||||
assert "x_search" not in lowered
|
||||
assert "web_search" not in lowered
|
||||
|
||||
|
||||
def test_xurl_skill_search_is_distinct_standalone():
|
||||
"""Search must be described so an agent can route correctly even when
|
||||
another X search surface exists — raw engageable posts, authenticated."""
|
||||
text = _read(XURL_SKILL)
|
||||
assert _contains_any(text, "raw post")
|
||||
assert _contains_any(text, "authenticated")
|
||||
assert _contains_any(text, "engage", "engageable")
|
||||
# Distinguish from synthesized-answer surfaces in xurl's own terms.
|
||||
assert _contains_any(text, "summarized answer", "summary of a topic")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_x_search_doc_separates_discovery_from_account_actions():
|
||||
text = _read(X_SEARCH_DOC)
|
||||
lowered = text.lower()
|
||||
|
||||
assert "x_search" in lowered
|
||||
assert "xurl" in lowered
|
||||
# Explicit comparison section or equivalent boundary language.
|
||||
assert _contains_any(text, "vs `xurl`", "vs xurl", "two different x surfaces")
|
||||
assert _contains_any(text, "read-only public", "public x discovery")
|
||||
assert _contains_any(
|
||||
text,
|
||||
"posting",
|
||||
"replying",
|
||||
"liking",
|
||||
"dm",
|
||||
"media upload",
|
||||
"deleting",
|
||||
)
|
||||
assert _contains_any(
|
||||
text,
|
||||
"authenticated",
|
||||
"exact or authenticated",
|
||||
"account actions",
|
||||
"state-changing",
|
||||
)
|
||||
# Write confirmation must come from xurl / X API, not x_search.
|
||||
assert _contains_any(
|
||||
text,
|
||||
"confirmed by `xurl`",
|
||||
"xurl` output",
|
||||
"x api response",
|
||||
"never evidence",
|
||||
)
|
||||
assert _contains_any(text, "switch to the `xurl`", "switch to `xurl`", "xurl skill")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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_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 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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user