Files
Aperant/tests/test_cli_qa_commands.py
AndyMik90andClaude Opus 4.6 1f60699f38 fix: resolve QA validation deadlock when subtasks are stuck or failed
When a coding agent marks a subtask as "stuck", QA validation would never
start because is_build_complete() requires ALL subtasks to have status
"completed". This creates a deadlock: coder exits (no more subtasks to
work on), but QA never triggers.

Changes:
- Add is_build_ready_for_qa() that considers builds ready when all
  subtasks reach a terminal state (completed, failed, or stuck)
- Update mark_subtask_stuck() to also set status="failed" in
  implementation_plan.json, keeping plan file in sync with reality
- Reorder QA loop to check human feedback before build completeness,
  so QA_FIX_REQUEST.md bypasses the build gate as intended
- Replace is_build_complete() with is_build_ready_for_qa() in
  should_run_qa() and CLI qa commands
- Add 20 new tests covering is_build_ready_for_qa() edge cases and
  mark_subtask_stuck() plan update behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-17 15:33:06 +01:00

582 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Tests for CLI QA Commands
==========================
Tests for qa_commands.py module functionality including:
- handle_qa_status_command() - Display QA status for a spec
- handle_review_status_command() - Display review status for a spec
- handle_qa_command() - Run QA validation loop
"""
import json
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from cli.qa_commands import (
handle_qa_command,
handle_qa_status_command,
handle_review_status_command,
)
from review import ReviewState
# =============================================================================
# FIXTURES
# =============================================================================
@pytest.fixture
def spec_dir_with_qa_report(temp_dir: Path) -> Path:
"""Create a spec directory with QA report."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
qa_report = spec_dir / "qa_report.md"
qa_report.write_text(
"# QA Report\n\n"
"## Status: Approved\n\n"
"All tests passed.\n"
)
return spec_dir
@pytest.fixture
def spec_dir_with_fix_request(temp_dir: Path) -> Path:
"""Create a spec directory with QA fix request."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
fix_request = spec_dir / "QA_FIX_REQUEST.md"
fix_request.write_text(
"# QA Fix Request\n\n"
"## Issues Found\n\n"
"1. Unit tests failing\n"
"2. Missing error handling\n"
)
return spec_dir
@pytest.fixture
def spec_dir_with_implementation_plan(temp_dir: Path) -> Path:
"""Create a spec directory with implementation plan (incomplete)."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"phase": 1,
"name": "Phase 1",
"subtasks": [
{"id": "1-1", "status": "completed"},
{"id": "1-2", "status": "pending"},
]
}
]
}
plan_file = spec_dir / "implementation_plan.json"
plan_file.write_text(json.dumps(plan))
return spec_dir
@pytest.fixture
def spec_dir_complete(temp_dir: Path) -> Path:
"""Create a spec directory with complete implementation."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"phase": 1,
"name": "Phase 1",
"subtasks": [
{"id": "1-1", "status": "completed"},
{"id": "1-2", "status": "completed"},
]
}
]
}
plan_file = spec_dir / "implementation_plan.json"
plan_file.write_text(json.dumps(plan))
return spec_dir
@pytest.fixture
def spec_dir_with_review_state(temp_dir: Path) -> Path:
"""Create a spec directory with review state."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
# Create spec.md first so the hash can match
(spec_dir / "spec.md").write_text("# Test Spec\n")
review_state = ReviewState(
approved=True,
approved_by="test_user",
approved_at="2024-01-15T10:30:00",
feedback=["Looks good!"],
spec_hash="", # Empty hash will be calculated and should match
review_count=1,
)
review_state.save(spec_dir)
return spec_dir
@pytest.fixture
def spec_dir_with_review_state_changed(temp_dir: Path) -> Path:
"""Create a spec with approved review but changed spec."""
spec_dir = temp_dir / "001-test-spec"
spec_dir.mkdir()
# Save review state
review_state = ReviewState(
approved=True,
approved_by="test_user",
spec_hash="old_hash",
)
review_state.save(spec_dir)
# Create spec.md (will have different hash)
(spec_dir / "spec.md").write_text("# Updated Spec\n")
return spec_dir
# =============================================================================
# HANDLE_QA_STATUS_COMMAND TESTS
# =============================================================================
class TestHandleQaStatusCommand:
"""Tests for handle_qa_status_command() function."""
def test_prints_qa_status(self, capsys, spec_dir_with_qa_report: Path) -> None:
"""Prints QA status for the spec."""
handle_qa_status_command(spec_dir_with_qa_report)
captured = capsys.readouterr()
assert "001-test-spec" in captured.out
# Check that some QA status output is present
assert len(captured.out) > 0
def test_prints_banner(self, capsys, spec_dir_with_qa_report: Path) -> None:
"""Prints banner before status."""
handle_qa_status_command(spec_dir_with_qa_report)
captured = capsys.readouterr()
# Banner should be printed (check for some visual separator)
assert "001-test-spec" in captured.out
def test_handles_missing_qa_report(self, capsys, temp_dir: Path) -> None:
"""Handles spec directory without QA report gracefully."""
spec_dir = temp_dir / "001-no-qa"
spec_dir.mkdir()
handle_qa_status_command(spec_dir)
captured = capsys.readouterr()
# Should print something even without QA report
assert len(captured.out) > 0
# =============================================================================
# HANDLE_REVIEW_STATUS_COMMAND TESTS
# =============================================================================
class TestHandleReviewStatusCommand:
"""Tests for handle_review_status_command() function."""
def test_prints_review_status(self, capsys, spec_dir_with_review_state: Path) -> None:
"""Prints review status for the spec."""
handle_review_status_command(spec_dir_with_review_state)
captured = capsys.readouterr()
assert "001-test-spec" in captured.out
def test_shows_ready_to_build_when_approval_valid(
self, capsys, spec_dir_with_review_state: Path
) -> None:
"""Shows 'Ready to build' message when approval is valid."""
handle_review_status_command(spec_dir_with_review_state)
captured = capsys.readouterr()
assert "Ready to build" in captured.out
assert "approval is valid" in captured.out
def test_shows_re_review_required_when_spec_changed(
self, capsys, spec_dir_with_review_state_changed: Path
) -> None:
"""Shows 're-review required' message when spec changed after approval."""
handle_review_status_command(spec_dir_with_review_state_changed)
captured = capsys.readouterr()
assert "re-review required" in captured.out
assert "Spec changed" in captured.out
def test_shows_review_required_when_not_approved(
self, capsys, temp_dir: Path
) -> None:
"""Shows 'review required' message when spec is not approved."""
spec_dir = temp_dir / "001-not-approved"
spec_dir.mkdir()
(spec_dir / "spec.md").write_text("# Not Approved\n")
handle_review_status_command(spec_dir)
captured = capsys.readouterr()
assert "Review required" in captured.out
def test_prints_banner(self, capsys, spec_dir_with_review_state: Path) -> None:
"""Prints banner before review status."""
handle_review_status_command(spec_dir_with_review_state)
captured = capsys.readouterr()
assert "001-test-spec" in captured.out
# =============================================================================
# HANDLE_QA_COMMAND TESTS
# =============================================================================
class TestHandleQaCommand:
"""Tests for handle_qa_command() function."""
def test_already_approved_message(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Shows already approved message when QA already passed."""
# Create qa_report.md
(spec_dir_complete / "qa_report.md").write_text("# QA Approved\n")
# Mock both validate_environment and should_run_qa/is_qa_approved
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.should_run_qa', return_value=False):
with patch('cli.qa_commands.is_qa_approved', return_value=True):
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
# Should print the "already approved" message
assert "already approved" in captured.out
def test_incomplete_build_message(
self, capsys, spec_dir_with_implementation_plan: Path, temp_git_repo: Path
) -> None:
"""Shows incomplete build message when subtasks not complete."""
with patch('cli.qa_commands.validate_environment', return_value=True):
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_with_implementation_plan,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "Build not ready for QA" in captured.out
assert "1/2" in captured.out
def test_processes_human_feedback(
self, capsys, spec_dir_with_fix_request: Path, temp_git_repo: Path
) -> None:
"""Processes fix request when human feedback present."""
# Add implementation plan so should_run_qa would normally return True
plan = {
"phases": [
{
"phase": 1,
"subtasks": [
{"id": "1-1", "status": "completed"},
{"id": "1-2", "status": "completed"},
]
}
]
}
(spec_dir_with_fix_request / "implementation_plan.json").write_text(json.dumps(plan))
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.return_value = True
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_with_fix_request,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "Human feedback detected" in captured.out
assert "processing fix request" in captured.out
def test_runs_qa_validation_loop(
self, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Runs QA validation loop when conditions are met."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.return_value = True
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=True,
)
# Should run the validation loop
assert mock_loop.called
call_args = mock_loop.call_args
assert call_args[1]["project_dir"] == temp_git_repo
assert call_args[1]["spec_dir"] == spec_dir_complete
assert call_args[1]["model"] == "test-model"
assert call_args[1]["verbose"] is True
def test_qa_approved_message(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Shows QA approved message when validation passes."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.return_value = True
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "QA validation passed" in captured.out
assert "Ready for merge" in captured.out
def test_qa_incomplete_message(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Shows incomplete message and exits when validation fails."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.return_value = False
with pytest.raises(SystemExit) as exc_info:
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
assert exc_info.value.code == 1
def test_exits_on_invalid_environment(
self, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Exits when environment validation fails."""
with patch('cli.qa_commands.validate_environment', return_value=False):
with pytest.raises(SystemExit) as exc_info:
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
assert exc_info.value.code == 1
def test_handles_keyboard_interrupt(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Handles KeyboardInterrupt gracefully during QA loop."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.side_effect = KeyboardInterrupt()
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "QA validation paused" in captured.out
assert "--qa" in captured.out
def test_prints_banner(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Prints banner before running QA."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop'):
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
# Should show banner
assert "QA validation" in captured.out
# =============================================================================
# INTEGRATION TESTS
# =============================================================================
class TestQaCommandsIntegration:
"""Integration tests for QA commands."""
def test_qa_status_to_review_status_workflow(
self, capsys, spec_dir_with_review_state: Path
) -> None:
"""Test checking both QA and review status."""
# Check QA status
handle_qa_status_command(spec_dir_with_review_state)
capsys.readouterr()
# Check review status
handle_review_status_command(spec_dir_with_review_state)
captured = capsys.readouterr()
# Both should print spec name
assert "001-test-spec" in captured.out
def test_qa_command_with_complete_workflow(
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
) -> None:
"""Test full QA workflow from start to approval."""
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
# Simulate successful QA
mock_loop.return_value = True
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_complete,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "QA validation passed" in captured.out
def test_qa_command_with_fix_request_workflow(
self, capsys, spec_dir_with_fix_request: Path, temp_git_repo: Path
) -> None:
"""Test QA workflow with human feedback."""
# Mark as complete
plan = {
"phases": [
{
"phase": 1,
"subtasks": [
{"id": "1-1", "status": "completed"},
{"id": "1-2", "status": "completed"},
]
}
]
}
(spec_dir_with_fix_request / "implementation_plan.json").write_text(json.dumps(plan))
with patch('cli.qa_commands.validate_environment', return_value=True):
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
mock_loop.return_value = True
handle_qa_command(
project_dir=temp_git_repo,
spec_dir=spec_dir_with_fix_request,
model="test-model",
verbose=False,
)
captured = capsys.readouterr()
assert "Human feedback detected" in captured.out
assert "QA validation passed" in captured.out
def test_review_status_scenarios(
self, capsys, temp_dir: Path
) -> None:
"""Test different review status scenarios."""
# Scenario 1: No review state
spec_dir = temp_dir / "001-test"
spec_dir.mkdir()
(spec_dir / "spec.md").write_text("# Test\n")
handle_review_status_command(spec_dir)
captured = capsys.readouterr()
assert "Review required" in captured.out
# Scenario 2: Approved and valid
review_state = ReviewState(approved=True, spec_hash="")
review_state.save(spec_dir)
handle_review_status_command(spec_dir)
captured = capsys.readouterr()
# Should show either "Ready to build" or "APPROVED" status
assert "APPROVED" in captured.out or "Ready to build" in captured.out
# =============================================================================
# MODULE IMPORT PATH INSERTION TESTS
# =============================================================================
class TestModuleImportPathInsertion:
"""Tests for module-level path manipulation logic (line 15)."""
def test_inserts_parent_dir_to_sys_path_when_not_present(self):
"""
Test that line 15 executes: sys.path.insert(0, str(_PARENT_DIR))
This test covers the scenario where _PARENT_DIR is not in sys.path
when the module-level code executes.
"""
import importlib
# Use import_module to get the actual module object
qa_commands_module = importlib.import_module("cli.qa_commands")
# Get the parent dir that should be inserted by line 15
parent_dir_str = str(qa_commands_module._PARENT_DIR)
# Verify parent_dir_str is the apps/backend directory
# Use os.path.normpath for cross-platform path comparison
import os
normalized_path = os.path.normpath(parent_dir_str)
# Check that the normalized path contains apps/backend or apps\backend (Windows)
assert ("apps" + os.sep + "backend") in normalized_path or "apps/backend" in normalized_path or "apps\\backend" in normalized_path
# Save current sys.path state to restore later
original_path = sys.path.copy()
# Remove the parent dir from sys.path
for p in sys.path[:]:
if p == parent_dir_str or p.rstrip("/") == parent_dir_str.rstrip("/"):
sys.path.remove(p)
try:
# Verify parent_dir_str is NOT in sys.path now
assert parent_dir_str not in sys.path
# Reload the module - this should execute lines 14-15 since path is not present
importlib.reload(qa_commands_module)
# Verify the parent dir was added to sys.path by line 15
assert parent_dir_str in sys.path, f"Parent dir {parent_dir_str} should be in sys.path"
finally:
# Restore sys.path to original state
sys.path[:] = original_path