kin: auto-commit after pipeline
This commit is contained in:
parent
824341a972
commit
e3a286ef6f
5 changed files with 1598 additions and 6 deletions
|
|
@ -826,7 +826,7 @@ _WORKTREE_ROLES = {"backend_dev", "frontend_dev", "debugger"}
|
|||
_DEV_GUARD_ROLES = {"backend_dev", "frontend_dev", "debugger"}
|
||||
|
||||
|
||||
def _detect_test_command(project_path: str) -> str | None:
|
||||
def _detect_test_command(project_path: str, role: str | None = None) -> str | None:
|
||||
"""Auto-detect test command by inspecting project files.
|
||||
|
||||
Candidates (in priority order):
|
||||
|
|
@ -835,10 +835,22 @@ def _detect_test_command(project_path: str) -> str | None:
|
|||
3. pytest — pyproject.toml or setup.py exists
|
||||
4. npx tsc --noEmit — tsconfig.json exists
|
||||
|
||||
When role='backend_dev' and a Python project marker (pyproject.toml / setup.py)
|
||||
is present, pytest is returned directly — bypassing make test. This prevents
|
||||
false-positive failures in mixed projects whose Makefile test target also runs
|
||||
frontend (e.g. vitest) commands that may be unrelated to backend changes.
|
||||
|
||||
Returns the first matching command, or None if no framework is detected.
|
||||
"""
|
||||
path = Path(project_path)
|
||||
|
||||
# For backend_dev: Python project marker takes precedence over Makefile.
|
||||
# Rationale: make test in mixed projects often runs frontend tests too;
|
||||
# backend changes should only be validated by the Python test runner.
|
||||
if role == "backend_dev":
|
||||
if (path / "pyproject.toml").is_file() or (path / "setup.py").is_file():
|
||||
return f"{sys.executable} -m pytest"
|
||||
|
||||
# 1. make test
|
||||
makefile = path / "Makefile"
|
||||
if makefile.is_file():
|
||||
|
|
@ -1882,7 +1894,7 @@ def run_pipeline(
|
|||
if p_test_cmd_override:
|
||||
p_test_cmd = p_test_cmd_override
|
||||
else:
|
||||
p_test_cmd = _detect_test_command(p_path_str)
|
||||
p_test_cmd = _detect_test_command(p_path_str, role=role)
|
||||
|
||||
if p_test_cmd is None:
|
||||
# No test framework detected — skip without blocking pipeline
|
||||
|
|
|
|||
|
|
@ -126,16 +126,25 @@ def generate_followups(
|
|||
parsed = _try_parse_json(output)
|
||||
if not isinstance(parsed, list):
|
||||
if isinstance(parsed, dict):
|
||||
parsed = parsed.get("tasks") or parsed.get("followups") or []
|
||||
if "tasks" in parsed:
|
||||
parsed = parsed["tasks"]
|
||||
elif "followups" in parsed:
|
||||
parsed = parsed["followups"]
|
||||
else:
|
||||
parsed = []
|
||||
else:
|
||||
return {"created": [], "pending_actions": []}
|
||||
|
||||
# Guard: extracted value might be null/non-list (e.g. {"tasks": null})
|
||||
if not isinstance(parsed, list):
|
||||
parsed = []
|
||||
|
||||
# Separate permission-blocked items from normal ones
|
||||
created = []
|
||||
pending_actions = []
|
||||
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict) or "title" not in item:
|
||||
if not isinstance(item, dict) or not item.get("title"):
|
||||
continue
|
||||
|
||||
if _is_permission_blocked(item):
|
||||
|
|
|
|||
|
|
@ -31,13 +31,27 @@ def validate_completion_mode(value: str) -> str:
|
|||
return "review"
|
||||
|
||||
|
||||
# Columns that are stored as JSON strings and must be decoded on read.
|
||||
# Text fields (title, description, name, etc.) are NOT in this set.
|
||||
_JSON_COLUMNS: frozenset[str] = frozenset({
|
||||
"tech_stack",
|
||||
"brief", "spec", "review", "test_result", "security_result", "labels",
|
||||
"tags",
|
||||
"dependencies",
|
||||
"steps",
|
||||
"artifacts", "decisions_made", "blockers",
|
||||
"extra_json",
|
||||
"pending_actions",
|
||||
})
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
|
||||
"""Convert sqlite3.Row to dict with JSON fields decoded."""
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for key, val in d.items():
|
||||
if isinstance(val, str) and val.startswith(("[", "{")):
|
||||
if key in _JSON_COLUMNS and isinstance(val, str) and val.startswith(("[", "{")):
|
||||
try:
|
||||
d[key] = json.loads(val)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
245
tests/test_kin_124_regression.py
Normal file
245
tests/test_kin_124_regression.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Regression tests for KIN-124 — auto-test ложно определяет failure.
|
||||
|
||||
Root cause: make test запускал vitest после pytest, vitest падал на всех 16
|
||||
тест-файлах с useI18n() без плагина i18n → make test возвращал exit code != 0
|
||||
→ auto-test считал весь прогон failed.
|
||||
|
||||
Исправления:
|
||||
1. web/frontend/vite.config.ts: добавлен setupFiles с vitest-setup.ts
|
||||
2. web/frontend/src/__tests__/vitest-setup.ts: глобальный i18n plugin для mount()
|
||||
3. _detect_test_command(role='backend_dev'): возвращает pytest напрямую (не make test)
|
||||
— это предотвращает запуск vitest при backend_dev auto-test
|
||||
|
||||
Coverage:
|
||||
(1) _run_project_tests: exit code 0 + "1533 passed" → success=True (главный регрессион)
|
||||
(2) _run_project_tests: exit code 1 + "2 failed" → success=False
|
||||
(3) _run_project_tests: exit code 0 + output содержит "failed" в середине → success=True
|
||||
(success определяется ТОЛЬКО по returncode, не по строке вывода)
|
||||
(4) _detect_test_command: backend_dev + pyproject.toml → возвращает pytest, не make test
|
||||
(5) _detect_test_command: backend_dev + pyproject.toml + Makefile → всё равно pytest
|
||||
(6) _detect_test_command: frontend_dev + Makefile с test: → возвращает make test
|
||||
(7) _detect_test_command: frontend_dev + pyproject.toml (без Makefile) → возвращает pytest
|
||||
(8) _run_project_tests: timeout → success=False, returncode=124
|
||||
(9) _run_project_tests: команда не найдена → success=False, returncode=127
|
||||
(10) vite.config.ts содержит setupFiles с vitest-setup.ts
|
||||
(11) vitest-setup.ts устанавливает i18n plugin глобально
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_subprocess_result(returncode: int, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
"""Build a MagicMock simulating subprocess.CompletedProcess."""
|
||||
r = MagicMock()
|
||||
r.returncode = returncode
|
||||
r.stdout = stdout
|
||||
r.stderr = stderr
|
||||
return r
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (1-3) _run_project_tests: success determined solely by returncode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunProjectTestsSuccessDetermination:
|
||||
"""_run_project_tests must use returncode, never parse stdout for pass/fail."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_exit_code_0_with_1533_passed_returns_success_true(self, mock_run):
|
||||
"""Regression KIN-124: exit code 0 + '1533 passed' → success=True."""
|
||||
from agents.runner import _run_project_tests
|
||||
mock_run.return_value = _make_subprocess_result(
|
||||
returncode=0,
|
||||
stdout="===================== 1533 passed in 42.7s =====================\n",
|
||||
)
|
||||
result = _run_project_tests("/tmp/proj", "pytest")
|
||||
assert result["success"] is True, (
|
||||
f"Expected success=True for returncode=0, got: {result}"
|
||||
)
|
||||
assert result["returncode"] == 0
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_exit_code_1_with_failed_output_returns_success_false(self, mock_run):
|
||||
"""exit code 1 + '2 failed' output → success=False."""
|
||||
from agents.runner import _run_project_tests
|
||||
mock_run.return_value = _make_subprocess_result(
|
||||
returncode=1,
|
||||
stdout="FAILED tests/test_foo.py::test_bar\n2 failed, 10 passed in 3.1s\n",
|
||||
)
|
||||
result = _run_project_tests("/tmp/proj", "pytest")
|
||||
assert result["success"] is False, (
|
||||
f"Expected success=False for returncode=1, got: {result}"
|
||||
)
|
||||
assert result["returncode"] == 1
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_exit_code_0_with_failed_substring_in_output_returns_success_true(self, mock_run):
|
||||
"""exit code 0 but output has 'failed' in a log line → success=True.
|
||||
|
||||
Success must be based on returncode only — not string matching.
|
||||
Example: a test description containing 'failed' should not confuse auto-test.
|
||||
"""
|
||||
from agents.runner import _run_project_tests
|
||||
mock_run.return_value = _make_subprocess_result(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"tests/test_retry.py::test_handles_previously_failed_request PASSED\n"
|
||||
"1 passed in 0.5s\n"
|
||||
),
|
||||
)
|
||||
result = _run_project_tests("/tmp/proj", "pytest")
|
||||
assert result["success"] is True, (
|
||||
"success must be True when returncode=0, even if 'failed' appears in output"
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_output_is_concatenation_of_stdout_and_stderr(self, mock_run):
|
||||
"""output field = stdout + stderr (both captured)."""
|
||||
from agents.runner import _run_project_tests
|
||||
mock_run.return_value = _make_subprocess_result(
|
||||
returncode=0,
|
||||
stdout="1 passed\n",
|
||||
stderr="PytestWarning: something\n",
|
||||
)
|
||||
result = _run_project_tests("/tmp/proj", "pytest")
|
||||
assert "1 passed" in result["output"]
|
||||
assert "PytestWarning" in result["output"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (8-9) _run_project_tests: error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunProjectTestsErrorHandling:
|
||||
|
||||
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="pytest", timeout=60))
|
||||
def test_timeout_returns_success_false_and_returncode_124(self, mock_run):
|
||||
"""Timeout → success=False, returncode=124."""
|
||||
from agents.runner import _run_project_tests
|
||||
result = _run_project_tests("/tmp/proj", "pytest", timeout=60)
|
||||
assert result["success"] is False
|
||||
assert result["returncode"] == 124
|
||||
assert "timed out" in result["output"].lower()
|
||||
|
||||
@patch("subprocess.run", side_effect=FileNotFoundError("pytest: not found"))
|
||||
def test_command_not_found_returns_success_false_and_returncode_127(self, mock_run):
|
||||
"""Command not found → success=False, returncode=127."""
|
||||
from agents.runner import _run_project_tests
|
||||
result = _run_project_tests("/tmp/proj", "pytest")
|
||||
assert result["success"] is False
|
||||
assert result["returncode"] == 127
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (4-7) _detect_test_command: role-based logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectTestCommandRoleLogic:
|
||||
"""_detect_test_command must return pytest (not make test) for backend_dev
|
||||
when pyproject.toml is present. This prevents vitest from running during
|
||||
backend-only changes (the root cause of KIN-124)."""
|
||||
|
||||
def test_backend_dev_with_pyproject_toml_returns_pytest_not_make_test(self, tmp_path):
|
||||
"""Regression KIN-124: backend_dev + pyproject.toml → pytest, not make test."""
|
||||
from agents.runner import _detect_test_command
|
||||
# Create both pyproject.toml and Makefile with test target
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n")
|
||||
makefile = tmp_path / "Makefile"
|
||||
makefile.write_text("test:\n\tmake test\n")
|
||||
|
||||
cmd = _detect_test_command(str(tmp_path), role="backend_dev")
|
||||
assert cmd is not None
|
||||
assert "pytest" in cmd, (
|
||||
f"Expected pytest command for backend_dev, got: {cmd!r}. "
|
||||
"backend_dev must not run make test (which triggers vitest)."
|
||||
)
|
||||
assert "make" not in cmd, (
|
||||
f"backend_dev must not use make test, got: {cmd!r}"
|
||||
)
|
||||
|
||||
def test_backend_dev_with_only_pyproject_toml_returns_pytest(self, tmp_path):
|
||||
"""backend_dev + only pyproject.toml (no Makefile) → pytest."""
|
||||
from agents.runner import _detect_test_command
|
||||
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
|
||||
cmd = _detect_test_command(str(tmp_path), role="backend_dev")
|
||||
assert cmd is not None
|
||||
assert "pytest" in cmd
|
||||
|
||||
def test_frontend_dev_with_makefile_returns_make_test(self, tmp_path):
|
||||
"""frontend_dev + Makefile with test: target → make test (correct for frontend)."""
|
||||
from agents.runner import _detect_test_command
|
||||
(tmp_path / "Makefile").write_text("test:\n\tnpm test\n")
|
||||
cmd = _detect_test_command(str(tmp_path), role="frontend_dev")
|
||||
assert cmd == "make test", (
|
||||
f"Expected 'make test' for frontend_dev with Makefile, got: {cmd!r}"
|
||||
)
|
||||
|
||||
def test_frontend_dev_with_pyproject_toml_no_makefile_returns_pytest(self, tmp_path):
|
||||
"""frontend_dev + pyproject.toml (no Makefile) → pytest (fallback)."""
|
||||
from agents.runner import _detect_test_command
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest]\n")
|
||||
cmd = _detect_test_command(str(tmp_path), role="frontend_dev")
|
||||
assert cmd is not None
|
||||
assert "pytest" in cmd
|
||||
|
||||
def test_no_markers_returns_none(self, tmp_path):
|
||||
"""Empty directory → None (no test framework detected)."""
|
||||
from agents.runner import _detect_test_command
|
||||
cmd = _detect_test_command(str(tmp_path))
|
||||
assert cmd is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (10-11) Frontend vitest setup files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVitestSetupFiles:
|
||||
"""Verify the vitest setup changes that fix the KIN-124 root cause."""
|
||||
|
||||
def test_vite_config_has_setup_files(self):
|
||||
"""vite.config.ts must declare setupFiles pointing to vitest-setup.ts."""
|
||||
vite_config = Path(__file__).parent.parent / "web/frontend/vite.config.ts"
|
||||
assert vite_config.exists(), "vite.config.ts not found"
|
||||
content = vite_config.read_text()
|
||||
assert "setupFiles" in content, (
|
||||
"vite.config.ts must have setupFiles to load global vitest setup"
|
||||
)
|
||||
assert "vitest-setup" in content, (
|
||||
"setupFiles must reference vitest-setup.ts"
|
||||
)
|
||||
|
||||
def test_vitest_setup_file_exists(self):
|
||||
"""web/frontend/src/__tests__/vitest-setup.ts must exist."""
|
||||
setup_file = (
|
||||
Path(__file__).parent.parent
|
||||
/ "web/frontend/src/__tests__/vitest-setup.ts"
|
||||
)
|
||||
assert setup_file.exists(), (
|
||||
"vitest-setup.ts not found — global i18n setup is missing, "
|
||||
"vitest will fail on all useI18n() components"
|
||||
)
|
||||
|
||||
def test_vitest_setup_registers_i18n_plugin(self):
|
||||
"""vitest-setup.ts must register i18n as a global plugin."""
|
||||
setup_file = (
|
||||
Path(__file__).parent.parent
|
||||
/ "web/frontend/src/__tests__/vitest-setup.ts"
|
||||
)
|
||||
assert setup_file.exists()
|
||||
content = setup_file.read_text()
|
||||
assert "i18n" in content, (
|
||||
"vitest-setup.ts must register the i18n plugin"
|
||||
)
|
||||
assert "config.global.plugins" in content, (
|
||||
"vitest-setup.ts must set config.global.plugins to inject i18n into all mounts"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue