Files
2026-07-13 13:12:33 +08:00

297 lines
9.5 KiB
Python

"""Unit tests for the `opensquilla code-task` CLI subcommand."""
import subprocess
import sys
from pathlib import Path
import pytest
from typer.testing import CliRunner
import opensquilla.cli.codetask_cmd as codetask_cmd
from opensquilla.cli.codetask_cmd import codetask_app
runner = CliRunner()
def test_help_runs():
result = runner.invoke(codetask_app, ["--help"])
assert result.exit_code == 0
assert "solve" in result.output
def test_solve_help_lists_inputs():
result = runner.invoke(
codetask_app,
["solve", "--help"],
env={"COLUMNS": "120", "NO_COLOR": "1", "TERM": "dumb"},
terminal_width=120,
)
assert result.exit_code == 0
for opt in ("--repo", "--issue", "--task", "--task-file"):
assert opt in result.output
def test_stage_task_file_reads_stdin_and_prints_shell_quoted_path():
task_text = '第一行\nsecond line with spaces and "$chars"\n'
result = runner.invoke(codetask_app, ["stage-task-file"], input=task_text)
assert result.exit_code == 0, result.output
token = result.stdout.strip()
assert token
# POSIX output is shlex-quoted; Windows output is double-quoted. In both
# cases the path itself is the only stdout payload.
staged = token[1:-1] if token.startswith('"') and token.endswith('"') else token.strip("'")
staged_path = Path(staged)
assert staged_path.name.startswith("codetask-task-")
assert staged_path.suffix == ".txt"
assert staged_path.read_text(encoding="utf-8") == task_text
def test_smoke_imports_accepts_explicit_modules():
result = runner.invoke(codetask_app, ["smoke-imports", "--module", "json"])
assert result.exit_code == 0, result.output
assert '"success": true' in result.stdout
assert '"json"' in result.stdout
def test_smoke_imports_fails_on_missing_module():
result = runner.invoke(
codetask_app,
["smoke-imports", "--module", "opensquilla_missing_smoke_module_x"],
)
assert result.exit_code == 1
assert '"success": false' in result.stdout
assert "opensquilla_missing_smoke_module_x" in result.stdout
def test_smoke_router_reports_success(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
codetask_cmd,
"_smoke_router_runtime",
lambda: {
"success": True,
"available": True,
"tier": "c1",
"confidence": 0.88,
"source": "v4_phase3",
"route_class": "R1",
"model_version": "test",
},
)
result = runner.invoke(codetask_app, ["smoke-router"])
assert result.exit_code == 0, result.output
assert '"success": true' in result.stdout
assert '"source": "v4_phase3"' in result.stdout
def test_smoke_router_fails_when_runtime_unavailable(monkeypatch: pytest.MonkeyPatch):
def fail() -> dict[str, object]:
raise RuntimeError("failed to initialize V4 Phase 3 router: No module named 'sklearn'")
monkeypatch.setattr(codetask_cmd, "_smoke_router_runtime", fail)
result = runner.invoke(codetask_app, ["smoke-router"])
assert result.exit_code == 1
assert '"success": false' in result.stdout
assert "No module named" in result.stdout
def test_solve_requires_one_task_input():
# repo given, but no task input → exit 2.
result = runner.invoke(codetask_app, ["solve", "--repo", "/tmp/x", "--yes"])
assert result.exit_code == 2
assert "exactly one" in result.output
def test_solve_requires_repo_unless_scratch():
result = runner.invoke(codetask_app, ["solve", "--task", "do it", "--yes"])
assert result.exit_code == 2
assert "--repo" in result.output
def test_solve_rejects_two_task_inputs():
result = runner.invoke(
codetask_app,
["solve", "--repo", "/tmp/x", "--issue", "1", "--task", "y", "--yes"],
)
assert result.exit_code == 2
def test_solve_rejects_unknown_verification_mode():
result = runner.invoke(
codetask_app,
[
"solve",
"--repo",
"/tmp/x",
"--task",
"do it",
"--verification-mode",
"unknown",
"--yes",
],
)
assert result.exit_code == 2
assert "Unknown --verification-mode" in result.output
def test_scratch_rejects_repo():
result = runner.invoke(
codetask_app,
[
"solve",
"--repo",
"/tmp/x",
"--task",
"do it",
"--verification-mode",
"scratch",
"--yes",
],
)
assert result.exit_code == 2
assert "Do not pass --repo" in result.output
def test_scratch_rejects_issue():
result = runner.invoke(
codetask_app,
["solve", "--issue", "1", "--verification-mode", "scratch", "--yes"],
)
assert result.exit_code == 2
assert "not --issue" in result.output
def test_json_without_yes_is_refused():
# --json is non-interactive; the trusted-host gate must not be skipped
# silently (codex review #5). It must require an explicit --yes.
result = runner.invoke(codetask_app, ["solve", "--repo", "/tmp/x", "--task", "do it", "--json"])
assert result.exit_code == 2
assert "--yes" in result.output
def test_json_stdout_is_clean_with_marker_on_stderr(monkeypatch):
# --json must keep stdout pure JSON; the run-dir startup marker goes to
# stderr so a consumer reading stdout alone still gets parseable JSON.
import json as _json
import opensquilla.contrib.codetask.runner as ct_runner
from opensquilla.contrib.codetask.types import TaskResult, TaskState
fake = TaskResult(
task_slug="x",
run_id="codetask-task-fake",
state=TaskState.VERIFIED,
repo="/tmp/x",
base_ref="main",
branch="task/x",
source="inline",
artifact_dir="/tmp/x",
)
fake.verified = True
monkeypatch.setattr(ct_runner, "solve", lambda **kw: fake)
result = runner.invoke(
codetask_app,
["solve", "--repo", "/tmp/x", "--task", "do it", "--json", "--yes"],
)
assert result.exit_code == 0, result.output
# stdout parses as JSON on its own — the marker did not leak into it.
parsed = _json.loads(result.stdout)
assert parsed["state"] == "verified"
assert "[code-task]" not in result.stdout
# the run-dir announcement is on stderr.
assert "[code-task] run started" in result.stderr
# ─── (c) Non-TTY stdin must refuse the confirm prompt ─────────────────────
# Field report: agent launches code-task via background_process, which opens
# stdin as a PIPE but never writes to it. When --yes is silently dropped
# (e.g. by cmd.exe quoting on Windows), typer.confirm() blocks on
# click.input() reading from that PIPE forever — observed: 90 minutes of
# silent hang until background_process timeout. The fix: detect non-TTY
# stdin and exit 2 with a clear hint, so the agent sees the failure
# immediately and can recover (or surface it to the user).
def test_non_tty_stdin_refuses_confirm(monkeypatch):
"""No --yes + stdin is not a TTY → exit 2 with a 'stdin is not a terminal'
hint, NOT a hung confirm prompt. Simulates the background_process /
cron / CI case."""
monkeypatch.setattr(
sys, "stdin", _NonTty()
) # isatty() -> False
result = runner.invoke(
codetask_app,
["solve", "--repo", "/tmp/x", "--task", "do it"],
)
assert result.exit_code == 2, result.output
assert "--yes" in result.output
assert "stdin is not a terminal" in result.output
def test_non_tty_stdin_with_yes_proceeds(monkeypatch):
"""The non-TTY gate is bypassed when --yes is explicit — same as the
interactive flow. Sanity-check that the gate isn't over-broad."""
import opensquilla.contrib.codetask.runner as ct_runner
from opensquilla.contrib.codetask.types import TaskResult, TaskState
fake = TaskResult(
task_slug="x", run_id="codetask-task-fake", state=TaskState.VERIFIED,
repo="/tmp/x", base_ref="main", branch="task/x", source="inline",
artifact_dir="/tmp/x",
)
fake.verified = True
monkeypatch.setattr(ct_runner, "solve", lambda **kw: fake)
monkeypatch.setattr(sys, "stdin", _NonTty())
result = runner.invoke(
codetask_app,
["solve", "--repo", "/tmp/x", "--task", "do it", "--yes"],
)
assert result.exit_code == 0, result.output
def test_stdin_without_isatty_attr_treated_as_non_tty(monkeypatch):
"""Defensive: when sys.stdin is monkey-patched to a stand-in that has
no isatty attribute at all (StringIO/BytesIO in tests), the gate must
still treat it as non-TTY rather than crashing with AttributeError."""
class _NoIsattyAttr:
pass # explicitly no isatty method
monkeypatch.setattr(sys, "stdin", _NoIsattyAttr())
result = runner.invoke(
codetask_app,
["solve", "--repo", "/tmp/x", "--task", "do it"],
)
assert result.exit_code == 2, result.output
assert "stdin is not a terminal" in result.output
class _NonTty:
"""Minimal stdin stand-in: isatty() -> False, nothing else."""
def isatty(self) -> bool:
return False
def test_cli_main_does_not_import_codetask():
result = subprocess.run(
[
sys.executable,
"-c",
"import sys\n"
"import opensquilla.cli.main\n"
"assert 'opensquilla.contrib.codetask' not in sys.modules, "
"'CLI registration must stay lazy'\n",
],
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr