autocodabench-alpha / tests /test_cli.py
ktgiahieu's picture
Improve `autocodabench validate` and docs (#36)
5322a6a unverified
Raw
History Blame Contribute Delete
14.1 kB
"""CLI surface tests — keyless paths only."""
import pytest
from autocodabench.cli.main import main, _make_progress_renderer
from conftest import DEMO_SLUG
# --- create progress renderer (default vs --debug) -------------------------
_EVENTS = [
{"kind": "phase", "index": 2, "total": 3, "title": "Building the bundle",
"detail": "writing files; linting; zipping"},
{"kind": "tool_use", "name": "mcp__autocodabench__autocodabench_init_bundle",
"input": {"slug": "create"}},
{"kind": "tool_use", "name": "mcp__autocodabench__autocodabench_log_event",
"input": {"kind": "progress", "message": "Wrote the scoring program."}},
{"kind": "tool_result", "is_error": True,
"preview": "TypeError: unexpected keyword argument 'multi_class'"},
{"kind": "tool_result", "is_error": True,
"preview": "Cancelled: parallel tool call Bash(...) errored"},
{"kind": "tool_use", "name": "mcp__autocodabench__autocodabench_log_event",
"input": {"kind": "deviation", "message": "Removed multi_class; acc 0.92."}},
{"kind": "text", "text": "Let me wire the scoring program next."},
{"kind": "phase_done", "phase": "build", "ok": True, "num_turns": 23},
]
def _render(events, *, debug):
import io
import contextlib
buf = io.StringIO()
r = _make_progress_renderer(debug=debug)
with contextlib.redirect_stdout(buf):
for e in events:
r(e)
return buf.getvalue()
def test_default_renderer_is_user_oriented():
out = _render(_EVENTS, debug=False)
# User-facing milestone + deviation messages are shown…
assert "Wrote the scoring program." in out
assert "Removed multi_class; acc 0.92." in out
# …tool calls are rendered as friendly actions (not raw tool ids)…
assert "Init bundle create" in out
assert "init_bundle" not in out
# …the agent's narration is shown (it is the user-friendly story)…
assert "Let me wire the scoring program next." in out
# …but raw errors and benign parallel cancellations are suppressed.
assert "TypeError" not in out
assert "Cancelled" not in out
def test_debug_renderer_shows_full_trace_and_softens_cancellations():
out = _render(_EVENTS, debug=True)
assert "Init bundle create" in out # friendly action…
assert "init_bundle" in out # …with the raw id kept greppable
assert "TypeError" in out # genuine error shown
assert "Let me wire the scoring program next." in out # narration shown
assert "Cancelled" not in out # cascade is reworded…
assert "retried" in out # …as a benign retry
# --- markdown-table rendering in narration (CLI-only) ----------------------
_TABLE_TEXT = (
"Plan ready. Summary:\n\n"
"| Section | Decision |\n"
"|---|---|\n"
"| **Task** | 3-class image classification; **result-submission** |\n"
"| **Metric** | `scipy.stats.gmean(per_group_accuracies)` |\n\n"
"Phase 2 will now run."
)
def test_narration_table_is_rendered_as_aligned_box():
out = _render([{"kind": "text", "text": _TABLE_TEXT}], debug=False)
# Drawn as a box with aligned borders…
assert "┌" in out and "┴" in out and "│" in out
# …header and cell content preserved, emphasis/backtick markers stripped…
assert "Section" in out and "Decision" in out
assert "scipy.stats.gmean(per_group_accuracies)" in out
assert "**Task**" not in out and "`scipy" not in out
# …and the markdown separator row is not echoed as raw pipes.
assert "|---|" not in out
# Surrounding prose is still shown.
assert "Plan ready. Summary:" in out
assert "Phase 2 will now run." in out
def test_table_detection_helpers():
from autocodabench.cli.progress import (
_has_md_table, _is_table_separator, _table_cells, _strip_md)
assert _has_md_table(_TABLE_TEXT)
assert not _has_md_table("just prose | with a stray pipe")
assert _is_table_separator("|---|:--:|")
assert not _is_table_separator("| a | b |")
assert _table_cells("| a | b |") == ["a", "b"]
assert _table_cells("no pipes here") is None
assert _strip_md("**bold** and `code`") == "bold and code"
def test_status_glyphs_are_colorized_on_tty():
from autocodabench.cli.progress import _colorize_glyphs, _GREEN, _YELLOW, _RED
out = _colorize_glyphs("✓ ⚠ ✗")
assert _GREEN in out and _YELLOW in out and _RED in out
# Non-glyph text is untouched.
assert _colorize_glyphs("plain") == "plain"
def test_provenance_table_renders_with_glyphs():
from autocodabench.cli.progress import ProgressUI, _GREEN, _RED
import io, contextlib
text = (
"| # | Dimension | Status | Origin |\n"
"|---|---|---|---|\n"
"| 1 | Task | ✓ | proposal §2 |\n"
"| 3 | Metric | ✗ | inferred |\n"
)
ui = ProgressUI(debug=False)
ui.animate = True # force ANSI so glyph colors are emitted
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
ui._on_text({"text": text})
out = buf.getvalue()
assert "┌" in out and "Dimension" in out # drawn as a box
assert _GREEN in out and _RED in out # ✓ green, ✗ red
assert "|---|" not in out # separator not echoed
def test_disp_width_counts_emoji_as_two():
from autocodabench.cli.progress import _disp_width, _pad
# ✅/❌ are East-Asian "Wide"; ⚠️ is base glyph + a U+FE0F selector — all 2.
assert _disp_width("✅") == 2
assert _disp_width("❌") == 2
assert _disp_width("⚠️") == 2
assert _disp_width("✓") == 1 and _disp_width("Status") == 6
# Emoji-aware ljust pads to the right *display* width.
assert _disp_width(_pad("✅", 6)) == 6
assert _disp_width(_pad("⚠️", 6)) == 6
def test_provenance_table_with_emoji_aligns():
"""The provenance table uses colour emoji (✅/⚠️/❌); the CLI box must still
align — every border/content row renders to the same display width."""
from autocodabench.cli.progress import ProgressUI, _disp_width
import io, contextlib
text = (
"| # | Dimension | Status | Origin |\n"
"|---|---|---|---|\n"
"| 1 | Task | ✅ | proposal §2 |\n"
"| 2 | Metric | ❌ | inferred |\n"
"| 3 | Data | ⚠️ | partial |\n"
)
ui = ProgressUI(debug=False)
ui.animate = False # plain output so widths are measurable
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
ui._on_text({"text": text})
out = buf.getvalue()
assert "✅" in out and "❌" in out and "⚠️" in out # emoji preserved
rows = [ln for ln in out.splitlines() if ln.strip() and ln.lstrip()[0] in "┌│├└"]
assert len({_disp_width(r) for r in rows}) == 1 # all rows same display width
def test_checks_list(capsys):
assert main(["checks", "list"]) == 0
out = capsys.readouterr().out
# Grouped by validation type, user-friendly (no internal ids), with the
# LLM-as-a-judge column header.
assert "[1. Structural]" in out and "[6. Governance]" in out
assert "LLM?" in out
assert "bundle-schema" not in out # internal id is hidden from users
assert "Bundle schema" in out # the friendly title shows (may wrap)
def test_checks_list_json_carries_type_and_llm(capsys):
assert main(["checks", "list", "--json"]) == 0
import json as _json
rows = _json.loads(capsys.readouterr().out)
r = next(x for x in rows if x["id"] == "bundle-schema")
assert r["type"] == "1. Structural" and r["llm_judged"] is False
assert r["how"] and r["citation_url"]
def test_demo_then_validate(tmp_path, capsys):
out_dir = tmp_path / "demo-out"
assert main(["demo", "--out", str(out_dir)]) == 0
out = capsys.readouterr().out
assert "no LLM, no keys" in out
assert "Bundle validation — ✅ PASS" in out
assert main(["validate", str(out_dir / DEMO_SLUG), "--no-execute"]) == 0
assert "Bundle validation" in capsys.readouterr().out
def test_validate_json_output(demo_bundle, capsys):
assert main(["validate", str(demo_bundle), "--no-execute", "--json"]) == 0
out = capsys.readouterr().out
assert '"ok": true' in out
def test_validate_exit_code_on_gate_failure(demo_bundle, capsys):
(demo_bundle / "pages" / "terms.md").unlink()
assert main(["validate", str(demo_bundle), "--no-execute"]) == 1
def test_version(capsys):
with pytest.raises(SystemExit) as exc:
main(["--version"])
assert exc.value.code == 0
def test_create_requires_idea_or_pdf(capsys):
# Neither an idea nor --pdf → guard returns 2 before any backend/auth touch.
assert main(["create"]) == 2
assert "idea" in capsys.readouterr().err.lower()
def test_create_pdf_must_exist(capsys):
assert main(["create", "--pdf", "/no/such/proposal.pdf"]) == 2
assert "not a file" in capsys.readouterr().err.lower()
def test_plan_requires_idea_or_pdf(capsys):
assert main(["plan"]) == 2
assert "idea" in capsys.readouterr().err.lower()
def test_plan_pdf_must_exist(capsys):
assert main(["plan", "--pdf", "/no/such/proposal.pdf"]) == 2
assert "not a file" in capsys.readouterr().err.lower()
# --- docker preflight banner ----------------------------------------------
import autocodabench.runner as _runner
from autocodabench.cli.main import _bundle_declared_image, _print_docker_preflight
def _fake_preflight(**over):
base = {
"host_arch": "arm64", "host_os": "Darwin",
"docker": {"cli_installed": True, "daemon_running": True,
"os": "linux", "arch": "arm64", "server_version": "27.0"},
"image": "codalab/codalab-legacy:py312",
"image_present_locally": True, "image_arch": "arm64",
"image_available_arches": ["arm64"], "image_multi_arch": False,
"image_source": "local image", "image_error": None,
"runs_natively": True, "emulated": False, "ready": True,
}
base.update(over)
return base
def test_preflight_banner_native(monkeypatch, capsys):
monkeypatch.setattr(_runner, "docker_preflight", lambda image: _fake_preflight())
_print_docker_preflight("codalab/codalab-legacy:py312", required=False)
out = capsys.readouterr().out
assert "Docker runtime" in out
assert "runs natively" in out
assert "arm64 (Darwin)" in out
def test_preflight_banner_emulated_warns(monkeypatch, capsys):
monkeypatch.delenv("AUTOCODABENCH_ALLOW_EMULATION", raising=False)
monkeypatch.setattr(_runner, "docker_preflight", lambda image: _fake_preflight(
image="codalab/codalab-legacy:py39", image_arch="amd64",
image_available_arches=["amd64"], image_present_locally=False,
image_source="remote manifest", runs_natively=False, emulated=True))
_print_docker_preflight("codalab/codalab-legacy:py39", required=True)
out = capsys.readouterr().out
assert "QEMU emulation" in out
# When execution is required but the image emulates, the banner flags the slow
# emulated run and recommends the OVERRIDE env (which wins over a declared
# image) or --no-execute. (validate then prompts the user interactively.)
assert "would run under slow emulation" in out
assert "AUTOCODABENCH_DOCKER_IMAGE_OVERRIDE=codalab/codalab-legacy:py312" in out
assert "--no-execute" in out
def test_preflight_banner_no_daemon_warns_when_required(monkeypatch, capsys):
monkeypatch.setattr(_runner, "docker_preflight", lambda image: _fake_preflight(
docker={"cli_installed": False, "daemon_running": False,
"os": None, "arch": None, "server_version": None},
ready=False, runs_natively=None, emulated=None, image_arch=None,
image_available_arches=[], image_error="Docker daemon not running"))
_print_docker_preflight("any/img:1", required=True)
cap = capsys.readouterr()
assert "Docker is not installed" in cap.out
assert "WARNING:" in cap.err # loud only when the run requires Docker
def test_bundle_declared_image_from_dir(demo_bundle):
# The shipped demo declares a codalab image in competition.yaml.
img = _bundle_declared_image(demo_bundle)
assert img and "codalab" in img
def test_bundle_declared_image_from_zip(tmp_path):
import zipfile
z = tmp_path / "b.zip"
with zipfile.ZipFile(z, "w") as zf:
zf.writestr("competition.yaml", "title: t\ndocker_image: myorg/img:9\n")
assert _bundle_declared_image(z) == "myorg/img:9"
def test_bundle_declared_image_none_when_absent(tmp_path):
(tmp_path / "competition.yaml").write_text("title: t\n")
assert _bundle_declared_image(tmp_path) is None
# ---------------------------------------------------------------------------
# build argument guards (keyless: rejected before any live-auth probe
# or backend call, so these run without credentials)
# ---------------------------------------------------------------------------
def test_build_requires_a_plan_source(capsys):
assert main(["build", "--yes"]) == 2
assert "plan file" in capsys.readouterr().err
def test_build_rejects_both_sources(tmp_path, capsys):
plan = tmp_path / "plan.md"
plan.write_text("# plan", encoding="utf-8")
code = main(["build", str(plan), "--run-dir", str(tmp_path), "--yes"])
assert code == 2
assert "not both" in capsys.readouterr().err
def test_build_missing_run_dir(tmp_path, capsys):
missing = tmp_path / "nope"
assert main(["build", "--run-dir", str(missing), "--yes"]) == 2
assert "run dir not found" in capsys.readouterr().err
def test_build_run_dir_without_plan(tmp_path, capsys):
(tmp_path / "specs").mkdir()
assert main(["build", "--run-dir", str(tmp_path), "--yes"]) == 2
assert "implementation_plan.md" in capsys.readouterr().err
def test_build_missing_plan_file(tmp_path, capsys):
assert main(["build", str(tmp_path / "absent.md"), "--yes"]) == 2
assert "plan file not found" in capsys.readouterr().err