Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Shared test fixtures."""
"""Shared test fixtures.

Reusable non-fixture helpers live in ``tests.support`` (mock git/PR helpers, etc.).
"""

import os

import boto3
import pytest
from fastapi.testclient import TestClient
from moto import mock_aws

os.environ.setdefault("PIPELINE_LOG", "/tmp/test-pipeline.log")
Expand Down Expand Up @@ -70,6 +74,21 @@ def tmp_tasks():
yield store


@pytest.fixture
def client(tmp_tasks, monkeypatch):
"""FastAPI TestClient with a fresh DynamoTaskStore; auth off; runner/cancel are no-ops."""
import src.routers.tasks as tasks_router
import src.web as web_mod

monkeypatch.setattr(tasks_router, "_get_store", lambda: tmp_tasks)
monkeypatch.setattr(web_mod, "trigger_runner", lambda task_id: None)
monkeypatch.setattr(web_mod, "cancel_runner", lambda task_id: None)
monkeypatch.setattr(web_mod, "AUTH_ENABLED", False)
from src.web import app

return TestClient(app, raise_server_exceptions=True)


@pytest.fixture
def tmp_log(tmp_path, monkeypatch):
"""Provide a temporary pipeline log path and patch the module."""
Expand All @@ -78,6 +97,6 @@ def tmp_log(tmp_path, monkeypatch):

monkeypatch.setattr(pl, "LOG_PATH", log_path)
monkeypatch.setattr(pl, "_handler_attached", False)
monkeypatch.setattr(pl, "_dynamo_log_store", None)
monkeypatch.setattr(pl, "_dynamo_log_store", False)
pl._pipeline_logger.handlers.clear()
return log_path
76 changes: 76 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
Shared helpers for tests — mock git/subprocess shapes, PR store stubs, command dispatch.

Import from ``tests.support`` in test modules (not collected as tests).
"""

from typing import Any, Callable, Optional, Tuple, Union
from unittest.mock import MagicMock

# ---------------------------------------------------------------------------
# Subprocess / git CLI mocks (``src.pr._run_cmd`` and similar)
# ---------------------------------------------------------------------------


def mock_process(returncode=0, stdout="", stderr=""):
# type: (int, str, str) -> MagicMock
"""Build a MagicMock matching ``subprocess.CompletedProcess`` fields tests read."""
m = MagicMock()
m.returncode = returncode
m.stdout = stdout
m.stderr = stderr
return m


def git_cmd_join(cmd):
# type: (Any) -> str
"""Join a argv list the same way PR tests match substrings (``"git status" in joined``)."""
if isinstance(cmd, list):
return " ".join(str(c) for c in cmd)
return str(cmd)


GitCmdRule = Union[
Tuple[str, Any],
Tuple[Callable[[Any, Optional[str], str], bool], Any],
]


def git_cmd_side_effect(rules, default=None):
# type: (list, Any) -> Callable[..., Any]
"""Return ``side_effect`` for a mock ``_run_cmd``.

Each *rule* is either:

- ``(substring, result)`` — first match where *substring* appears in the joined argv wins.
- ``(predicate, result)`` — *predicate* is ``(cmd, cwd, joined) -> bool``.

Rules are evaluated in order; use specific predicates before broad substrings.
"""
if default is None:
default = mock_process()

def side_effect(cmd, cwd=None, timeout=None):
joined = git_cmd_join(cmd)
for first, result in rules:
if isinstance(first, str):
if first in joined:
return result
elif first(cmd, cwd, joined):
return result
return default

return side_effect


# ---------------------------------------------------------------------------
# DynamoTaskStore stubs for PR pipeline tests
# ---------------------------------------------------------------------------


def attach_pr_mocks(store):
# type: (Any) -> Any
"""Stub ``set_pr_url`` / ``append_section`` so PR code does not hit Dynamo for those paths."""
store.set_pr_url = MagicMock()
store.append_section = MagicMock()
return store
39 changes: 39 additions & 0 deletions tests/test_agent_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Tests for agent prompt helpers (project context injection)."""

from types import SimpleNamespace

from src.agent import _project_context_markdown, build_prompt


def test_project_context_markdown_empty_id():
assert _project_context_markdown("") == ""


def test_project_context_markdown_missing_project(monkeypatch):
monkeypatch.setattr("src.projects_dynamo.get_project", lambda _pid: None)
assert _project_context_markdown("any") == ""


def test_project_context_markdown_with_spec(monkeypatch):
monkeypatch.setattr(
"src.projects_dynamo.get_project",
lambda pid: (
{"title": " App ", "spec": " Do things "} if pid == "p1" else None
),
)
md = _project_context_markdown("p1")
assert "**App**" in md
assert "Do things" in md


def test_build_prompt_includes_working_dir():
task = SimpleNamespace(
title="T",
description=None,
tags=None,
role="",
project_id="",
)
out = build_prompt(task, agent_cwd="/tmp/wt")
assert "/tmp/wt" in out
assert "YOUR WORKING DIRECTORY" in out
19 changes: 0 additions & 19 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,9 @@
Auth is disabled (AUTH_EMAIL/AUTH_PASSWORD unset in conftest).
"""

import pytest
from fastapi.testclient import TestClient

import src.routers.tasks as tasks_router
import src.web as web_mod
from src.task_store import TaskStatus


@pytest.fixture
def client(tmp_tasks, monkeypatch):
"""TestClient wired to a fresh DynamoTaskStore with no auth and no real runner."""
monkeypatch.setattr(tasks_router, "_get_store", lambda: tmp_tasks)
monkeypatch.setattr(web_mod, "trigger_runner", lambda task_id: None)
monkeypatch.setattr(web_mod, "cancel_runner", lambda task_id: None)
# Tests assume auth is off; host env may set AUTH_EMAIL/PASSWORD (setdefault in conftest
# does not override). Force-disable so /api/* returns 200, not 401.
monkeypatch.setattr(web_mod, "AUTH_ENABLED", False)
from src.web import app

return TestClient(app, raise_server_exceptions=True)


# ---------------------------------------------------------------------------
# GET /api/tasks
# ---------------------------------------------------------------------------
Expand Down
13 changes: 8 additions & 5 deletions tests/test_dynamo_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,6 @@ def get_item(Key, **kwargs):
assert found[0].id == "child"


def test_list_tasks_for_project_empty():
store = _make_store(MagicMock())
assert store.list_tasks_for_project("") == []


def test_maybe_finalize_directive_batch(monkeypatch):
table = MagicMock()
store = _make_store(table)
Expand Down Expand Up @@ -542,3 +537,11 @@ def test_add_comment_success():
assert c is not None
assert c.author == "me"
table.put_item.assert_called_once()


def test_list_tasks_for_project_empty_id_returns_empty(tmp_tasks):
assert tmp_tasks.list_tasks_for_project("") == []


def test_list_human_reply_pending_empty_project_returns_empty(tmp_tasks):
assert tmp_tasks.list_human_reply_pending_for_project("") == []
69 changes: 69 additions & 0 deletions tests/test_healer_stale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for heal_stale_worktrees and run_healer orchestration."""

import os
import time
from unittest.mock import MagicMock

from src.task_store import TaskStatus


class TestHealStaleWorktrees:
def test_removes_old_worktree_when_task_not_in_progress(
self, tmp_tasks, tmp_path, monkeypatch
):
from src.healer import heal_stale_worktrees

base = tmp_path / "worktrees"
base.mkdir()
monkeypatch.setattr("src.worktree.WORKTREE_BASE", base)
monkeypatch.setenv("STALE_WORKTREE_DAYS", "0")

task = tmp_tasks.create(title="Stale")
tid = task.id
wt = base / ("task-%s" % tid)
wt.mkdir()
old = time.time() - 10 * 86400
os.utime(str(wt), (old, old))

plog_calls = []

def plog(*a, **k):
plog_calls.append((a, k))

n = heal_stale_worktrees(tmp_tasks, plog)
assert n == 1
assert not wt.exists()
assert any("heal_stale_worktree" in str(c) for c in plog_calls)

def test_skips_in_progress_task(self, tmp_tasks, tmp_path, monkeypatch):
from src.healer import heal_stale_worktrees

base = tmp_path / "worktrees2"
base.mkdir()
monkeypatch.setattr("src.worktree.WORKTREE_BASE", base)
monkeypatch.setenv("STALE_WORKTREE_DAYS", "0")

task = tmp_tasks.create(title="Running")
tmp_tasks.update_status(task.id, TaskStatus.IN_PROGRESS)
tid = task.id
wt = base / ("task-%s" % tid)
wt.mkdir()
old = time.time() - 10 * 86400
os.utime(str(wt), (old, old))

n = heal_stale_worktrees(tmp_tasks, lambda *a, **k: None)
assert n == 0
assert wt.exists()


class TestRunHealer:
def test_returns_tuple_from_subhealers(self, monkeypatch):
from src.healer import run_healer

monkeypatch.setattr("src.healer.heal_stale_in_progress", lambda s, p: 2)
monkeypatch.setattr("src.healer.heal_branch_no_pr", lambda s, p: 3)
monkeypatch.setattr("src.healer.heal_cancelled_with_work", lambda s, p: 5)
monkeypatch.setattr("src.healer.heal_stale_worktrees", lambda s, p: 7)

out = run_healer(MagicMock())
assert out == (2, 3, 5, 7)
32 changes: 32 additions & 0 deletions tests/test_health_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ def test_last_log_timestamp_fallback_line(tmp_path):
assert out


def test_last_log_timestamp_uses_last_non_json_line(tmp_path):
p = tmp_path / "mixed.log"
p.write_text("older-line\nlast-line-plain\n")
assert _last_log_timestamp(p) == "last-line-plain"


def test_last_log_timestamp_skips_frontmatter_dashes(tmp_path):
p = tmp_path / "fm.log"
p.write_text("---\nuse-this-line\n")
out = _last_log_timestamp(p)
assert "use-this" in out


def test_last_log_timestamp_open_oserror(tmp_path, monkeypatch):
def boom(*_a, **_kw):
raise OSError("denied")

monkeypatch.setattr("builtins.open", boom)
assert _last_log_timestamp(tmp_path / "any.log") == ""


def test_api_health(monkeypatch):
monkeypatch.setattr("src.web.store", MagicMock(list_tasks=lambda: []))
client = TestClient(app)
Expand All @@ -50,3 +71,14 @@ def test_api_health(monkeypatch):
"failed",
"cancelled",
}


def test_api_health_survives_store_error(monkeypatch):
def boom():
raise RuntimeError("ddb")

monkeypatch.setattr("src.web.store", MagicMock(list_tasks=boom))
client = TestClient(app)
r = client.get("/api/health")
assert r.status_code == 200
assert r.json()["task_counts"] == {}
Loading
Loading