Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""Tests for the verify environment manifest and the smoke runner."""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.verify.environment import (
|
||||
load_manifest,
|
||||
load_or_detect,
|
||||
manifest_path,
|
||||
save_manifest,
|
||||
)
|
||||
from agent.verify.recipes import Recipe
|
||||
from agent.verify.runner import run_verify
|
||||
|
||||
|
||||
class TestManifest:
|
||||
def test_roundtrip(self, tmp_path):
|
||||
recipe = Recipe(
|
||||
name="Next.js",
|
||||
kind="nextjs",
|
||||
bootstrap=["npm install"],
|
||||
build=["npm run build"],
|
||||
test=["npm test"],
|
||||
start="npm run dev",
|
||||
port=3000,
|
||||
readiness_path="/health",
|
||||
)
|
||||
path = save_manifest(tmp_path, recipe)
|
||||
assert path == manifest_path(tmp_path)
|
||||
payload = json.loads(path.read_text())
|
||||
assert payload["version"] == 1
|
||||
assert "updatedAt" in payload
|
||||
assert load_manifest(tmp_path) == recipe
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
assert load_manifest(tmp_path) is None
|
||||
|
||||
def test_malformed_json_tolerated(self, tmp_path):
|
||||
path = manifest_path(tmp_path)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{oops", encoding="utf-8")
|
||||
assert load_manifest(tmp_path) is None
|
||||
|
||||
def test_non_dict_tolerated(self, tmp_path):
|
||||
path = manifest_path(tmp_path)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
assert load_manifest(tmp_path) is None
|
||||
|
||||
def test_bare_recipe_shape_accepted(self, tmp_path):
|
||||
path = manifest_path(tmp_path)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"name": "Custom", "test": ["true"]}), encoding="utf-8")
|
||||
recipe = load_manifest(tmp_path)
|
||||
assert recipe.name == "Custom"
|
||||
assert recipe.test == ["true"]
|
||||
|
||||
def test_manifest_wins_over_detection(self, tmp_path):
|
||||
(tmp_path / "go.mod").write_text("module x\n", encoding="utf-8")
|
||||
save_manifest(tmp_path, Recipe(name="Custom", kind="custom", test=["true"]))
|
||||
recipe, source = load_or_detect(tmp_path)
|
||||
assert source == "manifest"
|
||||
assert recipe.name == "Custom"
|
||||
|
||||
def test_detection_fallback(self, tmp_path):
|
||||
(tmp_path / "go.mod").write_text("module x\n", encoding="utf-8")
|
||||
recipe, source = load_or_detect(tmp_path)
|
||||
assert source == "detected"
|
||||
assert recipe.kind == "go"
|
||||
|
||||
|
||||
class TestRunner:
|
||||
def test_all_phases_pass(self, tmp_path):
|
||||
recipe = Recipe(name="x", bootstrap=["true"], build=["true"], test=["true"])
|
||||
result = run_verify(tmp_path, recipe, skip_start=True)
|
||||
assert result.ok
|
||||
assert [p.phase for p in result.phases] == ["bootstrap", "build", "test"]
|
||||
assert all(p.exit_code == 0 for p in result.phases)
|
||||
assert all(p.duration >= 0 for p in result.phases)
|
||||
|
||||
def test_failure_stops_pipeline(self, tmp_path):
|
||||
recipe = Recipe(name="x", build=["false"], test=["true"])
|
||||
result = run_verify(tmp_path, recipe, skip_start=True)
|
||||
assert not result.ok
|
||||
assert len(result.phases) == 1
|
||||
assert result.phases[0].exit_code == 1
|
||||
|
||||
def test_output_captured(self, tmp_path):
|
||||
recipe = Recipe(name="x", test=["echo hello-verify"])
|
||||
result = run_verify(tmp_path, recipe, skip_start=True)
|
||||
assert "hello-verify" in result.phases[0].output_tail
|
||||
|
||||
def test_phase_selection(self, tmp_path):
|
||||
recipe = Recipe(name="x", bootstrap=["true"], build=["true"], test=["true"])
|
||||
result = run_verify(tmp_path, recipe, phases=("test",))
|
||||
assert [p.phase for p in result.phases] == ["test"]
|
||||
|
||||
def test_phase_timeout(self, tmp_path):
|
||||
recipe = Recipe(name="x", test=["sleep 5"])
|
||||
result = run_verify(tmp_path, recipe, phase_timeout=0.3, skip_start=True)
|
||||
assert not result.ok
|
||||
assert result.phases[0].timed_out
|
||||
assert result.phases[0].exit_code is None
|
||||
|
||||
def test_commands_run_in_project_root(self, tmp_path):
|
||||
(tmp_path / "marker.txt").write_text("here", encoding="utf-8")
|
||||
recipe = Recipe(name="x", test=["cat marker.txt"])
|
||||
result = run_verify(tmp_path, recipe, skip_start=True)
|
||||
assert result.ok
|
||||
|
||||
def test_result_to_dict(self, tmp_path):
|
||||
recipe = Recipe(name="x", test=["true"])
|
||||
payload = run_verify(tmp_path, recipe, skip_start=True).to_dict()
|
||||
assert payload["ok"] is True
|
||||
assert payload["recipe"] == "x"
|
||||
assert payload["phases"][0]["command"] == "true"
|
||||
assert payload["readiness"] is None
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
import socket
|
||||
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class TestReadiness:
|
||||
def test_readiness_against_live_server(self, tmp_path):
|
||||
port = _free_port()
|
||||
recipe = Recipe(
|
||||
name="x",
|
||||
start=f"python3 -m http.server {port} --bind 127.0.0.1",
|
||||
port=port,
|
||||
)
|
||||
result = run_verify(tmp_path, recipe, phases=("start",), ready_timeout=15)
|
||||
assert result.readiness is not None
|
||||
assert result.readiness.ready
|
||||
assert result.readiness.status_code == 200
|
||||
assert result.readiness.url == f"http://127.0.0.1:{port}/"
|
||||
assert result.ok
|
||||
|
||||
def test_readiness_timeout_when_nothing_listens(self, tmp_path):
|
||||
port = _free_port()
|
||||
recipe = Recipe(name="x", start="sleep 30", port=port)
|
||||
result = run_verify(tmp_path, recipe, phases=("start",), ready_timeout=1.5)
|
||||
assert result.readiness is not None
|
||||
assert not result.readiness.ready
|
||||
assert not result.ok
|
||||
|
||||
def test_skip_start(self, tmp_path):
|
||||
recipe = Recipe(name="x", test=["true"], start="sleep 30", port=1)
|
||||
result = run_verify(tmp_path, recipe, skip_start=True)
|
||||
assert result.readiness is None
|
||||
assert result.ok
|
||||
|
||||
def test_start_skipped_after_phase_failure(self, tmp_path):
|
||||
recipe = Recipe(name="x", test=["false"], start="sleep 30", port=1)
|
||||
result = run_verify(tmp_path, recipe, stop_on_failure=False)
|
||||
assert result.readiness is None
|
||||
assert not result.ok
|
||||
|
||||
def test_port_override(self, tmp_path):
|
||||
port = _free_port()
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", port), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
recipe = Recipe(name="x", start="sleep 30", port=1)
|
||||
result = run_verify(
|
||||
tmp_path, recipe, phases=("start",), ready_timeout=10, port_override=port
|
||||
)
|
||||
assert result.readiness.ready
|
||||
assert result.readiness.status_code == 204
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Integration of the verify subsystem with the existing verification stack.
|
||||
|
||||
Covers the closed loop the rescoped PR is about:
|
||||
|
||||
- ``hermes verify`` records into the evidence ledger (pass and fail),
|
||||
- a passing run satisfies the verify-on-stop guard,
|
||||
- the verify-on-stop nudge names ``hermes verify --json`` when the workspace
|
||||
has a runnable recipe (start command or saved manifest),
|
||||
- the CLI's detect path merges ``detect_project_facts`` verify commands the
|
||||
recipe missed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.verification_evidence import (
|
||||
mark_workspace_edited,
|
||||
record_verify_run,
|
||||
verification_status,
|
||||
)
|
||||
from agent.verification_stop import build_verify_on_stop_nudge
|
||||
from hermes_cli.verify_cmd import run_verify_command
|
||||
|
||||
|
||||
def make_args(path, **overrides):
|
||||
defaults = dict(
|
||||
path=str(path),
|
||||
detect_only=False,
|
||||
save=False,
|
||||
skip_start=False,
|
||||
phase=None,
|
||||
port=None,
|
||||
timeout=60.0,
|
||||
ready_timeout=5.0,
|
||||
json=True,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes-home"))
|
||||
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _workspace(tmp_path, *, scripts=None, manifest_recipe=None):
|
||||
"""A marker-rooted workspace (package.json) with an optional saved recipe."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "package.json").write_text(
|
||||
json.dumps({"scripts": scripts} if scripts else {}), encoding="utf-8"
|
||||
)
|
||||
if manifest_recipe is not None:
|
||||
hermes_dir = project / ".hermes"
|
||||
hermes_dir.mkdir()
|
||||
(hermes_dir / "environment.json").write_text(
|
||||
json.dumps({"version": 1, "recipe": manifest_recipe}), encoding="utf-8"
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ledger recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_verify_run_marks_workspace_passed(hermes_home):
|
||||
project = _workspace(hermes_home)
|
||||
event = record_verify_run(root=project, session_id="s1", ok=True, output="all green")
|
||||
assert event is not None
|
||||
assert event["status"] == "passed"
|
||||
assert event["kind"] == "verify"
|
||||
status = verification_status(session_id="s1", cwd=project)
|
||||
assert status["status"] == "passed"
|
||||
assert status["evidence"]["canonical_command"] == "hermes verify"
|
||||
|
||||
|
||||
def test_record_verify_run_records_failure(hermes_home):
|
||||
project = _workspace(hermes_home)
|
||||
record_verify_run(root=project, session_id="s1", ok=False, output="boom")
|
||||
status = verification_status(session_id="s1", cwd=project)
|
||||
assert status["status"] == "failed"
|
||||
|
||||
|
||||
def test_cli_passing_run_writes_ledger_evidence(hermes_home, capsys):
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["echo ok"]})
|
||||
code = run_verify_command(make_args(project))
|
||||
assert code == 0
|
||||
assert json.loads(capsys.readouterr().out)["ok"] is True
|
||||
status = verification_status(session_id=None, cwd=project)
|
||||
assert status["status"] == "passed"
|
||||
assert status["evidence"]["scope"] == "full"
|
||||
|
||||
|
||||
def test_cli_failing_run_writes_failed_evidence(hermes_home, capsys):
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["false"]})
|
||||
code = run_verify_command(make_args(project))
|
||||
assert code == 1
|
||||
status = verification_status(session_id=None, cwd=project)
|
||||
assert status["status"] == "failed"
|
||||
|
||||
|
||||
def test_cli_partial_run_records_targeted_scope(hermes_home, capsys):
|
||||
# --skip-start / --phase subsets must never present as full workspace green.
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["echo ok"]})
|
||||
code = run_verify_command(make_args(project, skip_start=True))
|
||||
assert code == 0
|
||||
status = verification_status(session_id=None, cwd=project)
|
||||
assert status["evidence"]["scope"] == "targeted"
|
||||
|
||||
|
||||
def test_cli_run_uses_hermes_session_id_env(hermes_home, capsys, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_SESSION_ID", "sess-42")
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["echo ok"]})
|
||||
run_verify_command(make_args(project))
|
||||
assert verification_status(session_id="sess-42", cwd=project)["status"] == "passed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# closed loop: edit -> stop guard nudge -> hermes verify -> guard satisfied
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_passing_verify_run_satisfies_stop_guard(hermes_home, capsys):
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["echo ok"]})
|
||||
changed = str(project / "src" / "app.ts")
|
||||
mark_workspace_edited(session_id="default", cwd=project, paths=[changed])
|
||||
assert build_verify_on_stop_nudge(session_id="default", changed_paths=[changed]) is not None
|
||||
|
||||
assert run_verify_command(make_args(project)) == 0
|
||||
|
||||
assert build_verify_on_stop_nudge(session_id="default", changed_paths=[changed]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nudge wording: recipe-aware `hermes verify --json` suggestion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nudge_mentions_hermes_verify_when_recipe_has_start(hermes_home):
|
||||
project = _workspace(hermes_home, scripts={"test": "vitest", "dev": "vite"})
|
||||
changed = str(project / "src" / "app.ts")
|
||||
mark_workspace_edited(session_id="s1", cwd=project, paths=[changed])
|
||||
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
|
||||
assert nudge is not None
|
||||
assert "hermes verify --json" in nudge
|
||||
# The cheap verify commands are still listed first.
|
||||
assert "npm run test" in nudge
|
||||
|
||||
|
||||
def test_nudge_mentions_hermes_verify_when_manifest_exists(hermes_home):
|
||||
# No start script, but a saved .hermes/environment.json qualifies.
|
||||
project = _workspace(
|
||||
hermes_home,
|
||||
scripts={"test": "vitest"},
|
||||
manifest_recipe={"name": "Fake", "test": ["echo ok"]},
|
||||
)
|
||||
changed = str(project / "src" / "app.ts")
|
||||
mark_workspace_edited(session_id="s1", cwd=project, paths=[changed])
|
||||
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
|
||||
assert nudge is not None
|
||||
assert "hermes verify --json" in nudge
|
||||
|
||||
|
||||
def test_nudge_keeps_plain_wording_without_recipe_start(hermes_home):
|
||||
# Verify commands but no start script and no manifest: today's wording.
|
||||
project = _workspace(hermes_home, scripts={"test": "vitest"})
|
||||
changed = str(project / "src" / "app.ts")
|
||||
mark_workspace_edited(session_id="s1", cwd=project, paths=[changed])
|
||||
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
|
||||
assert nudge is not None
|
||||
assert "hermes verify" not in nudge
|
||||
|
||||
|
||||
def test_nudge_recipe_detection_failure_is_silent(hermes_home, monkeypatch):
|
||||
# A broken recipe detector must never break the nudge path.
|
||||
import agent.verify.recipes as recipes
|
||||
|
||||
def boom(_root):
|
||||
raise RuntimeError("detector exploded")
|
||||
|
||||
monkeypatch.setattr(recipes, "detect_recipe", boom)
|
||||
project = _workspace(hermes_home, scripts={"test": "vitest", "dev": "vite"})
|
||||
changed = str(project / "src" / "app.ts")
|
||||
mark_workspace_edited(session_id="s1", cwd=project, paths=[changed])
|
||||
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
|
||||
assert nudge is not None
|
||||
assert "hermes verify" not in nudge
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detection unification: project-facts commands merged into detected recipes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_path_merges_project_facts_commands(hermes_home, capsys):
|
||||
project = _workspace(hermes_home) # package.json with no scripts
|
||||
scripts_dir = project / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
(scripts_dir / "run_tests.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(project / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
|
||||
|
||||
code = run_verify_command(make_args(project, detect_only=True))
|
||||
assert code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["source"] == "detected"
|
||||
tests = payload["recipe"]["test"]
|
||||
assert "scripts/run_tests.sh" in tests
|
||||
assert "pytest" in tests
|
||||
|
||||
|
||||
def test_manifest_recipe_is_not_merged(hermes_home, capsys):
|
||||
# A saved manifest is the user-edited source of truth; leave it alone.
|
||||
project = _workspace(hermes_home, manifest_recipe={"name": "Fake", "test": ["echo ok"]})
|
||||
(project / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
|
||||
code = run_verify_command(make_args(project, detect_only=True))
|
||||
assert code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["source"] == "manifest"
|
||||
assert payload["recipe"]["test"] == ["echo ok"]
|
||||
|
||||
|
||||
def test_merge_skips_commands_recipe_already_has(hermes_home, capsys):
|
||||
project = _workspace(hermes_home, scripts={"test": "vitest"})
|
||||
code = run_verify_command(make_args(project, detect_only=True))
|
||||
assert code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["recipe"]["test"].count("npm run test") == 1
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Tests for agent/verify/recipes.py — static run-recipe detection."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.verify.recipes import Recipe, detect_package_manager, detect_recipe
|
||||
|
||||
|
||||
def write_pkg(root, data):
|
||||
(root / "package.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
class TestPackageManagerDetection:
|
||||
def test_pnpm_lock_wins(self, tmp_path):
|
||||
(tmp_path / "pnpm-lock.yaml").touch()
|
||||
(tmp_path / "yarn.lock").touch()
|
||||
assert detect_package_manager(tmp_path) == "pnpm"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lockfile,manager",
|
||||
[
|
||||
("bun.lock", "bun"),
|
||||
("bun.lockb", "bun"),
|
||||
("yarn.lock", "yarn"),
|
||||
("package-lock.json", "npm"),
|
||||
("uv.lock", "uv"),
|
||||
("poetry.lock", "poetry"),
|
||||
("Pipfile.lock", "pipenv"),
|
||||
],
|
||||
)
|
||||
def test_single_lockfile(self, tmp_path, lockfile, manager):
|
||||
(tmp_path / lockfile).touch()
|
||||
assert detect_package_manager(tmp_path) == manager
|
||||
|
||||
def test_no_lockfile(self, tmp_path):
|
||||
assert detect_package_manager(tmp_path) is None
|
||||
|
||||
|
||||
class TestNodeDetection:
|
||||
def test_nextjs_with_pnpm(self, tmp_path):
|
||||
write_pkg(
|
||||
tmp_path,
|
||||
{
|
||||
"dependencies": {"next": "14.0.0"},
|
||||
"scripts": {"dev": "next dev", "build": "next build", "test": "jest"},
|
||||
},
|
||||
)
|
||||
(tmp_path / "pnpm-lock.yaml").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "nextjs"
|
||||
assert recipe.bootstrap == ["pnpm install"]
|
||||
assert recipe.build == ["pnpm build"]
|
||||
assert recipe.test == ["pnpm test"]
|
||||
assert recipe.start == "pnpm dev"
|
||||
assert recipe.port == 3000
|
||||
|
||||
def test_vite_with_yarn(self, tmp_path):
|
||||
write_pkg(
|
||||
tmp_path,
|
||||
{
|
||||
"devDependencies": {"vite": "5.0.0"},
|
||||
"scripts": {"dev": "vite", "build": "vite build"},
|
||||
},
|
||||
)
|
||||
(tmp_path / "yarn.lock").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "vite"
|
||||
assert recipe.bootstrap == ["yarn install"]
|
||||
assert recipe.start == "yarn dev"
|
||||
assert recipe.port == 5173
|
||||
|
||||
def test_bun_runner(self, tmp_path):
|
||||
write_pkg(tmp_path, {"scripts": {"start": "node server.js", "build": "tsc"}})
|
||||
(tmp_path / "bun.lockb").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "node"
|
||||
assert recipe.bootstrap == ["bun install"]
|
||||
assert recipe.start == "bun run start"
|
||||
|
||||
def test_generic_node_defaults_to_npm(self, tmp_path):
|
||||
write_pkg(tmp_path, {"scripts": {"test": "mocha"}})
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.bootstrap == ["npm install"]
|
||||
assert recipe.test == ["npm run test"]
|
||||
assert recipe.start is None
|
||||
assert recipe.port is None
|
||||
|
||||
def test_port_inferred_from_start_command(self, tmp_path):
|
||||
write_pkg(tmp_path, {"scripts": {"dev": "node server.js --port 4111"}})
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.port == 4111
|
||||
|
||||
def test_cra(self, tmp_path):
|
||||
write_pkg(
|
||||
tmp_path,
|
||||
{"dependencies": {"react-scripts": "5.0"}, "scripts": {"start": "react-scripts start"}},
|
||||
)
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "cra"
|
||||
assert recipe.port == 3000
|
||||
|
||||
def test_malformed_package_json_falls_through(self, tmp_path):
|
||||
(tmp_path / "package.json").write_text("{not json", encoding="utf-8")
|
||||
(tmp_path / "go.mod").write_text("module x\n", encoding="utf-8")
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "go"
|
||||
|
||||
|
||||
class TestPythonDetection:
|
||||
def test_django_via_manage_py(self, tmp_path):
|
||||
(tmp_path / "manage.py").touch()
|
||||
(tmp_path / "requirements.txt").write_text("django\n", encoding="utf-8")
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "django"
|
||||
assert recipe.test == ["python manage.py test"]
|
||||
assert recipe.port == 8000
|
||||
assert "runserver" in recipe.start
|
||||
|
||||
def test_fastapi_uvicorn(self, tmp_path):
|
||||
(tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n", encoding="utf-8")
|
||||
(tmp_path / "main.py").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "fastapi"
|
||||
assert recipe.start.startswith("uvicorn main:app")
|
||||
assert recipe.port == 8000
|
||||
|
||||
def test_flask(self, tmp_path):
|
||||
(tmp_path / "requirements.txt").write_text("flask\n", encoding="utf-8")
|
||||
(tmp_path / "app.py").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "flask"
|
||||
assert recipe.port == 5000
|
||||
|
||||
def test_generic_python_uv(self, tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
|
||||
(tmp_path / "uv.lock").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "python"
|
||||
assert recipe.bootstrap == ["uv sync"]
|
||||
|
||||
def test_generic_python_pyproject_editable_install(self, tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.bootstrap == ["pip install -e ."]
|
||||
assert recipe.test == ["python -m unittest discover"]
|
||||
|
||||
def test_pytest_when_tests_dir(self, tmp_path):
|
||||
(tmp_path / "requirements.txt").write_text("requests\n", encoding="utf-8")
|
||||
(tmp_path / "tests").mkdir()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.test == ["pytest"]
|
||||
|
||||
|
||||
class TestOtherEcosystems:
|
||||
def test_go(self, tmp_path):
|
||||
(tmp_path / "go.mod").write_text("module example.com/x\n", encoding="utf-8")
|
||||
(tmp_path / "main.go").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "go"
|
||||
assert recipe.build == ["go build ./..."]
|
||||
assert recipe.start == "go run ."
|
||||
|
||||
def test_rust(self, tmp_path):
|
||||
(tmp_path / "Cargo.toml").write_text("[package]\nname='x'\n", encoding="utf-8")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.rs").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "rust"
|
||||
assert recipe.start == "cargo run"
|
||||
|
||||
def test_rust_library_has_no_start(self, tmp_path):
|
||||
(tmp_path / "Cargo.toml").write_text("[package]\nname='x'\n", encoding="utf-8")
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.start is None
|
||||
|
||||
def test_maven(self, tmp_path):
|
||||
(tmp_path / "pom.xml").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "maven"
|
||||
assert recipe.build == ["mvn package"]
|
||||
|
||||
def test_gradle_wrapper(self, tmp_path):
|
||||
(tmp_path / "build.gradle").touch()
|
||||
(tmp_path / "gradlew").touch()
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "gradle"
|
||||
assert recipe.build == ["./gradlew build"]
|
||||
|
||||
def test_makefile(self, tmp_path):
|
||||
(tmp_path / "Makefile").write_text(
|
||||
"install:\n\tpip install .\nbuild:\n\tmake -C src\ntest:\n\tpytest\nrun:\n\t./app\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "make"
|
||||
assert recipe.bootstrap == ["make install"]
|
||||
assert recipe.build == ["make build"]
|
||||
assert recipe.test == ["make test"]
|
||||
assert recipe.start == "make run"
|
||||
|
||||
def test_docker_compose(self, tmp_path):
|
||||
(tmp_path / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
|
||||
recipe = detect_recipe(tmp_path)
|
||||
assert recipe.kind == "compose"
|
||||
assert recipe.start == "docker compose up"
|
||||
|
||||
def test_empty_dir_returns_none(self, tmp_path):
|
||||
assert detect_recipe(tmp_path) is None
|
||||
|
||||
|
||||
class TestRecipeFromDict:
|
||||
def test_roundtrip(self):
|
||||
recipe = Recipe(name="X", kind="node", bootstrap=["npm install"], port=3000, start="npm run dev")
|
||||
restored = Recipe.from_dict(recipe.to_dict())
|
||||
assert restored == recipe
|
||||
|
||||
def test_tolerates_garbage(self):
|
||||
assert Recipe.from_dict(None) is None
|
||||
assert Recipe.from_dict([]) is None
|
||||
assert Recipe.from_dict({"kind": "x"}) is None # no name
|
||||
assert Recipe.from_dict({"name": " "}) is None
|
||||
|
||||
def test_grok_style_keys(self):
|
||||
recipe = Recipe.from_dict(
|
||||
{
|
||||
"appLabel": "Next.js",
|
||||
"appKind": "nextjs",
|
||||
"installCommands": ["npm install"],
|
||||
"buildCommands": ["npm run build"],
|
||||
"testCommands": ["npm test"],
|
||||
"startCommand": "npm run dev",
|
||||
"startPort": "3000",
|
||||
}
|
||||
)
|
||||
assert recipe.name == "Next.js"
|
||||
assert recipe.port == 3000
|
||||
assert recipe.bootstrap == ["npm install"]
|
||||
|
||||
def test_invalid_port_dropped(self):
|
||||
recipe = Recipe.from_dict({"name": "x", "port": "not-a-port"})
|
||||
assert recipe.port is None
|
||||
recipe = Recipe.from_dict({"name": "x", "port": 99999999})
|
||||
assert recipe.port is None
|
||||
|
||||
def test_bad_readiness_path_normalized(self):
|
||||
recipe = Recipe.from_dict({"name": "x", "readinessPath": "health"})
|
||||
assert recipe.readiness_path == "/"
|
||||
recipe = Recipe.from_dict({"name": "x", "readinessPath": "/health"})
|
||||
assert recipe.readiness_path == "/health"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for the ``hermes verify`` CLI command implementation."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from hermes_cli.verify_cmd import run_verify_command
|
||||
|
||||
|
||||
def make_args(path, **overrides):
|
||||
defaults = dict(
|
||||
path=str(path),
|
||||
detect_only=False,
|
||||
save=False,
|
||||
skip_start=False,
|
||||
phase=None,
|
||||
port=None,
|
||||
timeout=60.0,
|
||||
ready_timeout=5.0,
|
||||
json=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def test_detect_only_json(tmp_path, capsys):
|
||||
(tmp_path / "go.mod").write_text("module x\n", encoding="utf-8")
|
||||
code = run_verify_command(make_args(tmp_path, detect_only=True, json=True))
|
||||
assert code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["source"] == "detected"
|
||||
assert payload["recipe"]["kind"] == "go"
|
||||
assert payload["recipe"]["build"] == ["go build ./..."]
|
||||
|
||||
|
||||
def test_no_recipe_found(tmp_path, capsys):
|
||||
code = run_verify_command(make_args(tmp_path, detect_only=True))
|
||||
assert code == 1
|
||||
assert "No recognizable project" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_save_writes_manifest(tmp_path):
|
||||
(tmp_path / "go.mod").write_text("module x\n", encoding="utf-8")
|
||||
code = run_verify_command(make_args(tmp_path, detect_only=True, save=True, json=True))
|
||||
assert code == 0
|
||||
manifest = tmp_path / ".hermes" / "environment.json"
|
||||
assert manifest.exists()
|
||||
payload = json.loads(manifest.read_text())
|
||||
assert payload["version"] == 1
|
||||
assert payload["recipe"]["kind"] == "go"
|
||||
|
||||
|
||||
def test_run_phases_json(tmp_path, capsys):
|
||||
manifest = tmp_path / ".hermes"
|
||||
manifest.mkdir()
|
||||
(manifest / "environment.json").write_text(
|
||||
json.dumps({"recipe": {"name": "Fake", "test": ["echo ok"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
code = run_verify_command(make_args(tmp_path, json=True, skip_start=True))
|
||||
assert code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["ok"] is True
|
||||
assert payload["source"] == "manifest"
|
||||
assert payload["phases"][0]["command"] == "echo ok"
|
||||
|
||||
|
||||
def test_failing_phase_exit_code(tmp_path, capsys):
|
||||
manifest = tmp_path / ".hermes"
|
||||
manifest.mkdir()
|
||||
(manifest / "environment.json").write_text(
|
||||
json.dumps({"recipe": {"name": "Fake", "test": ["false"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
code = run_verify_command(make_args(tmp_path, json=True))
|
||||
assert code == 1
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["ok"] is False
|
||||
|
||||
|
||||
def test_human_report(tmp_path, capsys):
|
||||
manifest = tmp_path / ".hermes"
|
||||
manifest.mkdir()
|
||||
(manifest / "environment.json").write_text(
|
||||
json.dumps({"recipe": {"name": "Fake", "test": ["echo ok"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
code = run_verify_command(make_args(tmp_path, skip_start=True))
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert "Recipe: Fake" in out
|
||||
assert "PASS" in out
|
||||
assert "Result: OK" in out
|
||||
|
||||
|
||||
def test_bad_path(tmp_path, capsys):
|
||||
code = run_verify_command(make_args(tmp_path / "nope"))
|
||||
assert code == 2
|
||||
Reference in New Issue
Block a user