All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 20s
CI / typecheck (push) Successful in 28s
CI / test (3.12) (push) Successful in 1m41s
CI / test (3.13) (push) Successful in 1m35s
CI / test (3.14) (push) Successful in 1m28s
CI / complexity (push) Successful in 29s
182 lines
7.2 KiB
Python
182 lines
7.2 KiB
Python
"""Tests for .opencode/tools/project-status.ts — the project_status custom tool.
|
|
|
|
Mirrors ``tests/test_spec_status_tool.py``: exercises the tool's
|
|
``execute()`` function via ``tests/_ts_loader.mjs`` (a node CommonJS
|
|
sandbox that strips TS-only syntax, stubs ``@opencode-ai/plugin``, and
|
|
replaces ``import.meta.dir`` with the real ``.opencode/tools`` directory).
|
|
|
|
The loader is parameterized via the ``TS_FILE`` env var. These tests set
|
|
``TS_FILE=.opencode/tools/project-status.ts``. ``buildExecArgs`` was
|
|
extended in ``_ts_loader.mjs`` to support the multi-arg boolean tool
|
|
(``check`` + ``fast``): the raw value is split on ``|`` and each part is
|
|
mapped to a boolean via ``/^true$/i``.
|
|
|
|
Modes used:
|
|
- ``load`` — sanity-check that the tool loads and has ``check`` + ``fast`` args.
|
|
- ``exec_stub`` — call execute with a stubbed spawnSync to verify:
|
|
(a) ``--check`` / ``--fast`` flags added to argv correctly,
|
|
(b) stdout is trimmed on success,
|
|
(c) non-zero exit without ``check`` returns an actionable error message,
|
|
(d) non-zero exit WITH ``check`` returns the trimmed stdout (strict mode),
|
|
(e) ``cwd`` is propagated from ``context.worktree`` (ADR-023).
|
|
- ``exec_real`` — call execute against the real project-status.py
|
|
(integration test, non-blocking so always exit 0 in this repo).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def _gh_available() -> bool:
|
|
"""True if `gh auth status` succeeds (local dev machine, not CI runner)."""
|
|
try:
|
|
return (
|
|
subprocess.run(
|
|
["gh", "auth", "status"],
|
|
capture_output=True,
|
|
check=False,
|
|
).returncode
|
|
== 0
|
|
)
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
|
|
_GH_OK = _gh_available()
|
|
_SKIP_REASON = "gh CLI not authenticated — skip real project-status.py call"
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
|
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts"
|
|
TS_FILE_REL = ".opencode/tools/project-status.ts"
|
|
|
|
|
|
def _run_loader(*args: str, stdin: str | None = None) -> dict:
|
|
"""Invoke the loader with ``TS_FILE`` env set to project-status.ts and parse JSON stdout."""
|
|
env = {**os.environ, "TS_FILE": TS_FILE_REL}
|
|
proc = subprocess.run(
|
|
["node", str(LOADER), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=str(REPO_ROOT),
|
|
input=stdin,
|
|
timeout=60,
|
|
env=env,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
|
|
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
|
|
)
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def test_loader_can_load_tool():
|
|
"""Sanity: project-status.ts loads and has the ``check`` + ``fast`` args (optional)."""
|
|
if not TS_FILE.exists():
|
|
pytest.skip("project-status.ts not present")
|
|
out = _run_loader("load")
|
|
assert "description" in out
|
|
args = out["args"]
|
|
assert "check" in args, f"expected 'check' arg, got: {args}"
|
|
assert "fast" in args, f"expected 'fast' arg, got: {args}"
|
|
|
|
|
|
def test_execute_no_flags():
|
|
"""execute() with no flags calls spawnSync WITHOUT ``--check`` / ``--fast``.
|
|
|
|
rawValue="false|false" → both check and fast are false → cmdArgs = [].
|
|
"""
|
|
out = _run_loader("exec_stub", "false|false", "0", "project output", "")
|
|
calls = out["calls"]
|
|
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
|
|
call = calls[0]
|
|
assert call["cmd"] == "python3"
|
|
assert call["args"][0] == str(REPO_ROOT / ".opencode" / "scripts" / "project-status.py")
|
|
assert "--check" not in call["args"], f"unexpected --check: {call['args']}"
|
|
assert "--fast" not in call["args"], f"unexpected --fast: {call['args']}"
|
|
|
|
|
|
def test_execute_passes_check_flag():
|
|
"""execute() with ``check: true`` adds ``--check`` to spawnSync argv."""
|
|
out = _run_loader("exec_stub", "true|false", "0", "strict output", "")
|
|
calls = out["calls"]
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
assert "--check" in call["args"], f"expected --check in args: {call['args']}"
|
|
assert "--fast" not in call["args"], f"unexpected --fast: {call['args']}"
|
|
|
|
|
|
def test_execute_passes_fast_flag():
|
|
"""execute() with ``fast: true`` adds ``--fast`` to spawnSync argv."""
|
|
out = _run_loader("exec_stub", "false|true", "0", "fast output", "")
|
|
calls = out["calls"]
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
assert "--fast" in call["args"], f"expected --fast in args: {call['args']}"
|
|
assert "--check" not in call["args"], f"unexpected --check: {call['args']}"
|
|
|
|
|
|
def test_execute_passes_both_flags():
|
|
"""execute() with ``check: true`` + ``fast: true`` adds both flags."""
|
|
out = _run_loader("exec_stub", "true|true", "0", "both output", "")
|
|
calls = out["calls"]
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
assert "--check" in call["args"]
|
|
assert "--fast" in call["args"]
|
|
|
|
|
|
def test_execute_trims_stdout():
|
|
"""execute trims leading/trailing whitespace from the script stdout."""
|
|
raw_stdout = " trimmed-output \n"
|
|
out = _run_loader("exec_stub", "false|false", "0", raw_stdout, "")
|
|
result = out["result"]
|
|
assert result == "trimmed-output", f"expected trimmed output, got: {result!r}"
|
|
|
|
|
|
def test_execute_nonzero_exit_without_check_returns_error():
|
|
"""execute returns an actionable error message on non-zero exit WITHOUT --check."""
|
|
out = _run_loader("exec_stub", "false|false", "1", "", "some stderr from project-status")
|
|
result = out["result"]
|
|
assert "project_status failed" in result
|
|
assert "exit 1" in result
|
|
assert "some stderr from project-status" in result
|
|
|
|
|
|
def test_execute_nonzero_exit_with_check_returns_stdout():
|
|
"""execute with ``check: true`` returns stdout even on non-zero exit (strict)."""
|
|
out = _run_loader("exec_stub", "true|false", "1", "FAIL report here", "ignored stderr")
|
|
result = out["result"]
|
|
assert result == "FAIL report here", f"strict mode should return stdout, got: {result!r}"
|
|
|
|
|
|
def test_execute_uses_cwd_from_context():
|
|
"""execute passes ``cwd=context.worktree`` to spawnSync (ADR-023)."""
|
|
out = _run_loader("exec_stub", "false|false", "0", "ok", "")
|
|
calls = out["calls"]
|
|
assert len(calls) == 1
|
|
opts = calls[0]["opts"]
|
|
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
|
|
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
|
|
assert opts["cwd"] == str(REPO_ROOT), (
|
|
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not _GH_OK, reason=_SKIP_REASON)
|
|
def test_execute_real_project_status():
|
|
"""Integration: execute() returns the real project-status.py output (non-blocking)."""
|
|
if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists():
|
|
pytest.skip("project-status.py not present")
|
|
out = _run_loader("exec_real", "false|false")
|
|
if out.get("error"):
|
|
pytest.fail(f"execute raised: {out['error']}")
|
|
result = out["result"]
|
|
assert "Project:" in result, f"expected 'Project:' in output, got: {result[:200]!r}"
|
|
assert "Итог:" in result, f"expected 'Итог:' in output, got: {result[:200]!r}"
|