From 6c2da26b6ca8dd4f91878242d5afdbf6ebe27d89 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 15:40:31 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- agents/runner.py | 26 +++++++++------ core/db.py | 6 ++++ tests/test_api.py | 29 ++++++++++++++++ tests/test_kin_091_regression.py | 57 ++++++++++++++++++++++++++++++++ tests/test_models.py | 23 +++++++++++++ web/api.py | 7 ++++ 6 files changed, 138 insertions(+), 10 deletions(-) diff --git a/agents/runner.py b/agents/runner.py index f02a33d..d2b67c2 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -6,6 +6,7 @@ Each agent = separate process with isolated context. import json import logging import os +import shlex import shutil import sqlite3 import subprocess @@ -778,16 +779,20 @@ def _is_test_failure(result: dict) -> bool: _AUTO_TEST_ROLES = {"backend_dev", "frontend_dev"} -def _run_project_tests(project_path: str, timeout: int = 120) -> dict: - """Run `make test` in project_path. Returns {success, output, returncode}. +def _run_project_tests(project_path: str, test_command: str = 'make test', timeout: int = 120) -> dict: + """Run test_command in project_path. Returns {success, output, returncode}. Never raises — all errors are captured and returned in output. """ env = _build_claude_env() - make_cmd = shutil.which("make", path=env["PATH"]) or "make" + parts = shlex.split(test_command) + if not parts: + return {"success": False, "output": "Empty test_command", "returncode": -1} + resolved = shutil.which(parts[0], path=env["PATH"]) or parts[0] + cmd = [resolved] + parts[1:] try: result = subprocess.run( - [make_cmd, "test"], + cmd, cwd=project_path, capture_output=True, text=True, @@ -797,9 +802,9 @@ def _run_project_tests(project_path: str, timeout: int = 120) -> dict: output = (result.stdout or "") + (result.stderr or "") return {"success": result.returncode == 0, "output": output, "returncode": result.returncode} except subprocess.TimeoutExpired: - return {"success": False, "output": f"make test timed out after {timeout}s", "returncode": 124} + return {"success": False, "output": f"{test_command} timed out after {timeout}s", "returncode": 124} except FileNotFoundError: - return {"success": False, "output": "make not found — no Makefile or make not in PATH", "returncode": 127} + return {"success": False, "output": f"{parts[0]} not found in PATH", "returncode": 127} except Exception as exc: return {"success": False, "output": f"Test run error: {exc}", "returncode": -1} @@ -1568,14 +1573,15 @@ def run_pipeline( ): max_auto_test_attempts = int(os.environ.get("KIN_AUTO_TEST_MAX_ATTEMPTS") or 3) p_path_str = str(Path(project_for_wt["path"]).expanduser()) - test_run = _run_project_tests(p_path_str) + p_test_cmd = project_for_wt.get("test_command") or "make test" + test_run = _run_project_tests(p_path_str, p_test_cmd) results.append({"role": "_auto_test", "success": test_run["success"], "output": test_run["output"], "_project_test": True}) auto_test_attempt = 0 while not test_run["success"] and auto_test_attempt < max_auto_test_attempts: auto_test_attempt += 1 fix_context = ( - f"Automated project test run (make test) failed after your changes.\n" + f"Automated project test run ({p_test_cmd}) failed after your changes.\n" f"Test output:\n{test_run['output'][:4000]}\n" f"Fix the failing tests. Do NOT modify test files." ) @@ -1591,13 +1597,13 @@ def run_pipeline( total_tokens += fix_result.get("tokens_used") or 0 total_duration += fix_result.get("duration_seconds") or 0 results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt}) - test_run = _run_project_tests(p_path_str) + test_run = _run_project_tests(p_path_str, p_test_cmd) results.append({"role": "_auto_test", "success": test_run["success"], "output": test_run["output"], "_project_test": True, "_attempt": auto_test_attempt}) if not test_run["success"]: block_reason = ( - f"Auto-test (make test) failed after {auto_test_attempt} fix attempt(s). " + f"Auto-test ({p_test_cmd}) failed after {auto_test_attempt} fix attempt(s). " f"Last output: {test_run['output'][:500]}" ) models.update_task(conn, task_id, status="blocked", blocked_reason=block_reason) diff --git a/core/db.py b/core/db.py index feadd5d..0f7a522 100644 --- a/core/db.py +++ b/core/db.py @@ -640,6 +640,12 @@ def _migrate(conn: sqlite3.Connection): """) conn.commit() + # Add test_command column to projects (KIN-ARCH-008) + projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} + if "test_command" not in projects_cols: + conn.execute("ALTER TABLE projects ADD COLUMN test_command TEXT DEFAULT 'make test'") + conn.commit() + # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" diff --git a/tests/test_api.py b/tests/test_api.py index 626b9a1..d71bf25 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2122,3 +2122,32 @@ def test_run_kin085_parallel_different_tasks_not_blocked(client): # Запуск второй задачи должен быть успешным r = client.post("/api/tasks/P1-002/run") assert r.status_code == 202 + + +# --------------------------------------------------------------------------- +# KIN-ARCH-008: test_command на уровне проекта — API +# --------------------------------------------------------------------------- + +def test_patch_project_test_command(client): + """KIN-ARCH-008: PATCH /api/projects/{id} с test_command сохраняет значение.""" + r = client.patch("/api/projects/p1", json={"test_command": "pytest -v"}) + assert r.status_code == 200 + assert r.json()["test_command"] == "pytest -v" + + +def test_create_project_with_test_command(client): + """KIN-ARCH-008: POST /api/projects с test_command сохраняет значение в БД.""" + r = client.post("/api/projects", json={ + "id": "p_tc", + "name": "TC Project", + "path": "/tmp/tc", + "test_command": "npm test", + }) + assert r.status_code == 200 + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT test_command FROM projects WHERE id = 'p_tc'").fetchone() + conn.close() + assert row is not None + assert row[0] == "npm test" diff --git a/tests/test_kin_091_regression.py b/tests/test_kin_091_regression.py index ceb520a..e2b9f8a 100644 --- a/tests/test_kin_091_regression.py +++ b/tests/test_kin_091_regression.py @@ -176,6 +176,43 @@ class TestRunProjectTests: assert result["success"] is False assert result["returncode"] == 124 + def test_custom_test_command_used(self): + """KIN-ARCH-008: _run_project_tests вызывает subprocess с переданной командой.""" + from agents.runner import _run_project_tests + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2 passed" + mock_result.stderr = "" + with patch("agents.runner.subprocess.run", return_value=mock_result) as mock_sp, \ + patch("agents.runner.shutil.which", return_value=None): + _run_project_tests("/fake/path", test_command="pytest -v") + called_cmd = mock_sp.call_args[0][0] + assert called_cmd[0] == "pytest" + assert "-v" in called_cmd + + def test_default_test_command_is_make_test(self): + """KIN-ARCH-008: без test_command параметра вызывается 'make test'.""" + from agents.runner import _run_project_tests + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "OK" + mock_result.stderr = "" + with patch("agents.runner.subprocess.run", return_value=mock_result) as mock_sp, \ + patch("agents.runner.shutil.which", return_value=None): + _run_project_tests("/fake/path") + called_cmd = mock_sp.call_args[0][0] + assert called_cmd[0] == "make" + assert "test" in called_cmd + + def test_custom_command_not_found_returns_127(self): + """KIN-ARCH-008: кастомная команда не найдена → returncode 127.""" + from agents.runner import _run_project_tests + with patch("agents.runner.subprocess.run", side_effect=FileNotFoundError), \ + patch("agents.runner.shutil.which", return_value=None): + result = _run_project_tests("/fake/path", test_command="nonexistent-cmd --flag") + assert result["success"] is False + assert result["returncode"] == 127 + def _mock_success(output="done"): m = MagicMock() @@ -302,6 +339,26 @@ class TestAutoTestInPipeline: mock_tests.assert_not_called() + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_uses_project_test_command( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """KIN-ARCH-008: pipeline передаёт project.test_command в _run_project_tests.""" + from agents.runner import run_pipeline + from core import models + mock_run.return_value = _mock_success() + mock_tests.return_value = {"success": True, "output": "OK", "returncode": 0} + models.update_project(conn, "vdol", auto_test_enabled=True, test_command="npm test") + + steps = [{"role": "backend_dev", "brief": "implement"}] + run_pipeline(conn, "VDOL-001", steps) + + mock_tests.assert_called_once() + called_test_command = mock_tests.call_args[0][1] + assert called_test_command == "npm test" + # --------------------------------------------------------------------------- # (3) Spec-driven workflow route diff --git a/tests/test_models.py b/tests/test_models.py index 3a06e6d..d32ea46 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -734,3 +734,26 @@ def test_delete_attachment_returns_true(task_conn): def test_delete_attachment_not_found_returns_false(task_conn): """KIN-090: delete_attachment возвращает False если запись не найдена.""" assert models.delete_attachment(task_conn, 99999) is False + + +# --------------------------------------------------------------------------- +# KIN-ARCH-008: test_command на уровне проекта +# --------------------------------------------------------------------------- + +def test_schema_project_has_test_command_column(conn): + """KIN-ARCH-008: таблица projects содержит колонку test_command.""" + cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} + assert "test_command" in cols + + +def test_test_command_default_is_make_test(conn): + """KIN-ARCH-008: новый проект без test_command получает дефолт 'make test'.""" + p = models.create_project(conn, "prj_tc", "TC Project", "/tmp/tc") + assert p["test_command"] == "make test" + + +def test_test_command_can_be_set(conn): + """KIN-ARCH-008: update_project сохраняет кастомный test_command.""" + models.create_project(conn, "prj_tc2", "TC Project 2", "/tmp/tc2") + updated = models.update_project(conn, "prj_tc2", test_command="pytest -v --tb=short") + assert updated["test_command"] == "pytest -v --tb=short" diff --git a/web/api.py b/web/api.py index 62382bb..e331e65 100644 --- a/web/api.py +++ b/web/api.py @@ -206,6 +206,7 @@ class ProjectCreate(BaseModel): ssh_user: str | None = None ssh_key_path: str | None = None ssh_proxy_jump: str | None = None + test_command: str = 'make test' @model_validator(mode="after") def validate_fields(self) -> "ProjectCreate": @@ -222,6 +223,7 @@ class ProjectPatch(BaseModel): auto_test_enabled: bool | None = None obsidian_vault_path: str | None = None deploy_command: str | None = None + test_command: str | None = None project_type: str | None = None ssh_host: str | None = None ssh_user: str | None = None @@ -235,6 +237,7 @@ def patch_project(project_id: str, body: ProjectPatch): body.execution_mode, body.autocommit_enabled is not None, body.auto_test_enabled is not None, body.obsidian_vault_path, body.deploy_command is not None, + body.test_command is not None, body.project_type, body.ssh_host is not None, body.ssh_user is not None, body.ssh_key_path is not None, body.ssh_proxy_jump is not None, @@ -262,6 +265,8 @@ def patch_project(project_id: str, body: ProjectPatch): if body.deploy_command is not None: # Empty string = sentinel for clearing (decision #68) fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command + if body.test_command is not None: + fields["test_command"] = body.test_command if body.project_type is not None: fields["project_type"] = body.project_type if body.ssh_host is not None: @@ -366,6 +371,8 @@ def create_project(body: ProjectCreate): ssh_key_path=body.ssh_key_path, ssh_proxy_jump=body.ssh_proxy_jump, ) + if body.test_command != "make test": + p = models.update_project(conn, body.id, test_command=body.test_command) conn.close() return p