Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30855be0c8 | |||
| a96ce9164f | |||
| b14c4f4bec | |||
| d0478f4ec1 | |||
| 487f90b5b8 | |||
| 6d3a524bda | |||
| bf05d58e33 | |||
| 6602b83054 | |||
| 079dcb6341 | |||
| d769d0a5a7 | |||
| ccdd721d9c | |||
| e0e114dc9f | |||
| 1a893c05bb |
@@ -35,18 +35,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.3)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.4"
|
||||
__version__ = "2.7.6-beta.3"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -292,14 +292,6 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,11 +22,6 @@ except (ImportError, ValueError, SystemError):
|
||||
from file_lock import locked_json_update, locked_json_write
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
"""Return current UTC time as ISO 8601 string with timezone info."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class ReviewSeverity(str, Enum):
|
||||
"""Severity levels for PR review findings."""
|
||||
|
||||
@@ -526,7 +521,7 @@ class PRReviewResult:
|
||||
summary: str = ""
|
||||
overall_status: str = "comment" # approve, request_changes, comment
|
||||
review_id: int | None = None
|
||||
reviewed_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
reviewed_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
error: str | None = None
|
||||
|
||||
# NEW: Enhanced verdict system
|
||||
@@ -615,7 +610,7 @@ class PRReviewResult:
|
||||
summary=data.get("summary", ""),
|
||||
overall_status=data.get("overall_status", "comment"),
|
||||
review_id=data.get("review_id"),
|
||||
reviewed_at=data.get("reviewed_at", _utc_now_iso()),
|
||||
reviewed_at=data.get("reviewed_at", datetime.now().isoformat()),
|
||||
error=data.get("error"),
|
||||
# NEW fields
|
||||
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
||||
@@ -696,7 +691,7 @@ class PRReviewResult:
|
||||
reviews.append(entry)
|
||||
|
||||
current_data["reviews"] = reviews
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
return current_data
|
||||
|
||||
@@ -767,7 +762,7 @@ class TriageResult:
|
||||
suggested_breakdown: list[str] = field(default_factory=list)
|
||||
priority: str = "medium" # high, medium, low
|
||||
comment: str | None = None
|
||||
triaged_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
triaged_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -803,7 +798,7 @@ class TriageResult:
|
||||
suggested_breakdown=data.get("suggested_breakdown", []),
|
||||
priority=data.get("priority", "medium"),
|
||||
comment=data.get("comment"),
|
||||
triaged_at=data.get("triaged_at", _utc_now_iso()),
|
||||
triaged_at=data.get("triaged_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
@@ -841,8 +836,8 @@ class AutoFixState:
|
||||
pr_url: str | None = None
|
||||
bot_comments: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
updated_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -880,8 +875,8 @@ class AutoFixState:
|
||||
pr_url=data.get("pr_url"),
|
||||
bot_comments=data.get("bot_comments", []),
|
||||
error=data.get("error"),
|
||||
created_at=data.get("created_at", _utc_now_iso()),
|
||||
updated_at=data.get("updated_at", _utc_now_iso()),
|
||||
created_at=data.get("created_at", datetime.now().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
def update_status(self, status: AutoFixStatus) -> None:
|
||||
@@ -891,7 +886,7 @@ class AutoFixState:
|
||||
f"Invalid state transition: {self.status.value} -> {status.value}"
|
||||
)
|
||||
self.status = status
|
||||
self.updated_at = _utc_now_iso()
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
"""Save auto-fix state to .auto-claude/github/issues/ with file locking."""
|
||||
@@ -943,7 +938,7 @@ class AutoFixState:
|
||||
queue.append(entry)
|
||||
|
||||
current_data["auto_fix_queue"] = queue
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
return current_data
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -32,7 +33,6 @@ try:
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
@@ -46,7 +46,6 @@ except (ImportError, ValueError, SystemError):
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
@@ -266,7 +265,7 @@ class FollowupReviewer:
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_at=_utc_now_iso(),
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
# Follow-up specific fields
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
|
||||
@@ -51,7 +51,7 @@ try:
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import FollowupExtractionResponse, ParallelFollowupResponse
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
@@ -75,10 +75,7 @@ except (ImportError, ValueError, SystemError):
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
@@ -579,36 +576,16 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
stream_error = stream_result.get("error")
|
||||
if stream_error:
|
||||
if stream_result.get("error_recoverable"):
|
||||
# Recoverable error — attempt extraction call fallback
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Recoverable error: {stream_error}. "
|
||||
f"Attempting extraction call fallback."
|
||||
)
|
||||
safe_print(
|
||||
f"[ParallelFollowup] WARNING: {stream_error} — "
|
||||
f"attempting recovery with minimal extraction...",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
# Fatal error — raise as before
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_error}"
|
||||
)
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
last_assistant_text = stream_result.get("last_assistant_text", "")
|
||||
# Nullify structured output on recoverable errors to force Tier 2 fallback
|
||||
structured_output = (
|
||||
None
|
||||
if (stream_error and stream_result.get("error_recoverable"))
|
||||
else stream_result["structured_output"]
|
||||
)
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
@@ -619,28 +596,22 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (three-tier recovery cascade)
|
||||
# Parse findings from output
|
||||
if structured_output:
|
||||
result_data = self._parse_structured_output(structured_output, context)
|
||||
else:
|
||||
# Structured output missing or validation failed.
|
||||
# Tier 2: Attempt extraction call with minimal schema
|
||||
# Log when structured output is missing - this shouldn't happen normally
|
||||
# when output_format is configured, so it indicates a problem
|
||||
logger.warning(
|
||||
"[ParallelFollowup] No structured output — attempting extraction call"
|
||||
"[ParallelFollowup] No structured output received from SDK - "
|
||||
"falling back to text parsing. Resolution data may be incomplete."
|
||||
)
|
||||
# Use last_assistant_text (cleaner) if available, fall back to full transcript
|
||||
fallback_text = last_assistant_text or result_text
|
||||
result_data = await self._attempt_extraction_call(
|
||||
fallback_text, context
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Structured output not captured, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
)
|
||||
if result_data is None:
|
||||
# Tier 3: Fall back to basic text parsing
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Extraction call failed, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
|
||||
# Extract data
|
||||
findings = result_data.get("findings", [])
|
||||
@@ -759,9 +730,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Extract validation counts
|
||||
dismissed_count = len(
|
||||
result_data.get("dismissed_false_positive_ids", [])
|
||||
) or result_data.get("dismissed_finding_count", 0)
|
||||
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
|
||||
confirmed_count = result_data.get("confirmed_valid_count", 0)
|
||||
needs_human_count = result_data.get("needs_human_review_count", 0)
|
||||
|
||||
@@ -1105,129 +1074,17 @@ The SDK will run invoked agents in parallel automatically.
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": text[:500] if text else "Unable to parse response",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
async def _attempt_extraction_call(
|
||||
self, text: str, context: FollowupReviewContext
|
||||
) -> dict | None:
|
||||
"""Attempt a short SDK call with a minimal schema to recover review data.
|
||||
|
||||
This is the Tier 2 recovery step when full structured output validation fails.
|
||||
Uses FollowupExtractionResponse (~6 flat fields) which has near-100% success rate.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
logger.warning("[ParallelFollowup] No text available for extraction call")
|
||||
return None
|
||||
|
||||
try:
|
||||
safe_print(
|
||||
"[ParallelFollowup] Attempting recovery with minimal extraction schema...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
extraction_prompt = (
|
||||
"Extract the key review data from the following AI analysis output. "
|
||||
"Return the verdict, reasoning, resolved finding IDs, unresolved finding IDs, "
|
||||
"one-line summaries of any new findings, and counts of confirmed/dismissed findings.\n\n"
|
||||
f"--- AI ANALYSIS OUTPUT ---\n{text[:8000]}\n--- END ---"
|
||||
)
|
||||
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
|
||||
extraction_client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_extraction",
|
||||
fast_mode=self.config.fast_mode,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FollowupExtractionResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with extraction_client:
|
||||
await extraction_client.query(extraction_prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=extraction_client,
|
||||
context_name="FollowupExtraction",
|
||||
model=model,
|
||||
system_prompt=extraction_prompt,
|
||||
max_messages=20,
|
||||
)
|
||||
|
||||
if stream_result.get("error"):
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Extraction call also failed: {stream_result['error']}"
|
||||
)
|
||||
return None
|
||||
|
||||
extraction_output = stream_result.get("structured_output")
|
||||
if not extraction_output:
|
||||
logger.warning(
|
||||
"[ParallelFollowup] Extraction call returned no structured output"
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse the minimal extraction response
|
||||
extracted = FollowupExtractionResponse.model_validate(extraction_output)
|
||||
|
||||
# Map verdict string to MergeVerdict enum
|
||||
verdict_map = {
|
||||
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
|
||||
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
|
||||
"BLOCKED": MergeVerdict.BLOCKED,
|
||||
}
|
||||
verdict = verdict_map.get(extracted.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.new_finding_summaries)} new findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": [], # Full findings not recoverable via extraction
|
||||
"resolved_ids": extracted.resolved_finding_ids,
|
||||
"unresolved_ids": extracted.unresolved_finding_ids,
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": extracted.confirmed_finding_count,
|
||||
"dismissed_finding_count": extracted.dismissed_finding_count,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": f"[Recovered via extraction] {extracted.verdict_reasoning}",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[ParallelFollowup] Extraction call failed: {e}")
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction call failed: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _create_empty_result(self) -> dict:
|
||||
"""Create empty result structure."""
|
||||
return {
|
||||
@@ -1235,13 +1092,8 @@ The SDK will run invoked agents in parallel automatically.
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": MergeVerdict.NEEDS_REVISION,
|
||||
"verdict_reasoning": "Unable to parse review results",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
def _extract_partial_data(self, data: dict) -> dict | None:
|
||||
|
||||
@@ -1785,7 +1785,6 @@ For EACH finding above:
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
or "structured_output" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
|
||||
@@ -710,39 +710,3 @@ class FindingValidationResponse(BaseModel):
|
||||
"how many dismissed, how many need human review"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Minimal Extraction Schema (Fallback for structured output validation failure)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FollowupExtractionResponse(BaseModel):
|
||||
"""Minimal extraction schema for recovering data when full structured output fails.
|
||||
|
||||
Deliberately kept small (~6 fields, no nesting) for near-100% validation success.
|
||||
Used as an intermediate recovery step before falling back to raw text parsing.
|
||||
"""
|
||||
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
resolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that are now resolved",
|
||||
)
|
||||
unresolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that remain unresolved",
|
||||
)
|
||||
new_finding_summaries: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="One-line summary of each new finding (e.g. 'HIGH: cleanup deletes QA-rejected specs in batch_commands.py')",
|
||||
)
|
||||
confirmed_finding_count: int = Field(
|
||||
0, description="Number of findings confirmed as valid"
|
||||
)
|
||||
dismissed_finding_count: int = Field(
|
||||
0, description="Number of findings dismissed as false positives"
|
||||
)
|
||||
|
||||
@@ -133,13 +133,6 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
# Errors that are recoverable (callers can fall back to text parsing or retry)
|
||||
# vs fatal errors (auth failures, circuit breaker) that should propagate
|
||||
RECOVERABLE_ERRORS = {
|
||||
"structured_output_validation_failed",
|
||||
"tool_use_concurrency_error",
|
||||
}
|
||||
|
||||
# Abort after 1 consecutive repeat (2 total identical responses).
|
||||
# Low threshold catches error loops quickly (e.g., auth errors returned as AI text).
|
||||
# Normal AI responses never produce the exact same text block twice in a row.
|
||||
@@ -268,11 +261,8 @@ async def process_sdk_stream(
|
||||
- msg_count: Total message count
|
||||
- subagent_tool_ids: Mapping of tool_id -> agent_name
|
||||
- error: Error message if stream processing failed (None on success)
|
||||
- error_recoverable: Boolean indicating if the error is recoverable (fallback possible) vs fatal
|
||||
- last_assistant_text: Last non-empty assistant text block (for cleaner fallback parsing)
|
||||
"""
|
||||
result_text = ""
|
||||
last_assistant_text = "" # Last assistant text block (for cleaner fallback parsing)
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
@@ -491,9 +481,6 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Track last non-empty text for fallback parsing
|
||||
if block.text.strip():
|
||||
last_assistant_text = block.text
|
||||
# Check for auth/access error returned as AI response text.
|
||||
# Note: break exits this inner for-loop over msg.content;
|
||||
# the outer message loop exits via `if stream_error: break`.
|
||||
@@ -660,16 +647,11 @@ async def process_sdk_stream(
|
||||
f"[{context_name}] Tool use concurrency error detected - caller should retry"
|
||||
)
|
||||
|
||||
# Categorize error as recoverable (fallback possible) vs fatal
|
||||
error_recoverable = stream_error in RECOVERABLE_ERRORS if stream_error else False
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"last_assistant_text": last_assistant_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
"msg_count": msg_count,
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
"error_recoverable": error_recoverable,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.4",
|
||||
"version": "2.7.6-beta.3",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
|
||||
@@ -174,8 +174,6 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
|
||||
onSelectIssue={selectIssue}
|
||||
onInvestigate={handleInvestigate}
|
||||
onLoadMore={!isSearchActive ? handleLoadMore : undefined}
|
||||
onRetry={handleRefresh}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
-371
@@ -1,371 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Key,
|
||||
Shield,
|
||||
WifiOff,
|
||||
SearchX,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { parseGitHubError } from '../utils/github-error-parser';
|
||||
import type { GitHubErrorInfo, GitHubErrorType } from '../types';
|
||||
|
||||
/**
|
||||
* Props for the GitHubErrorDisplay component.
|
||||
*/
|
||||
export interface GitHubErrorDisplayProps {
|
||||
/** Raw error string or pre-parsed GitHubErrorInfo */
|
||||
error: string | GitHubErrorInfo | null;
|
||||
/** Callback when user clicks retry button */
|
||||
onRetry?: () => void;
|
||||
/** Callback when user clicks settings button */
|
||||
onOpenSettings?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show as compact inline error (vs full-width card) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for each error type: icon, color, title key.
|
||||
*/
|
||||
const ERROR_CONFIG: Record<
|
||||
GitHubErrorType,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
iconColorClass: string;
|
||||
}
|
||||
> = {
|
||||
rate_limit: {
|
||||
icon: Clock,
|
||||
titleKey: 'githubErrors.rateLimitTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
auth: {
|
||||
icon: Key,
|
||||
titleKey: 'githubErrors.authTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
permission: {
|
||||
icon: Shield,
|
||||
titleKey: 'githubErrors.permissionTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
not_found: {
|
||||
icon: SearchX,
|
||||
titleKey: 'githubErrors.notFoundTitle',
|
||||
iconColorClass: 'text-muted-foreground',
|
||||
},
|
||||
network: {
|
||||
icon: WifiOff,
|
||||
titleKey: 'githubErrors.networkTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
unknown: {
|
||||
icon: AlertTriangle,
|
||||
titleKey: 'githubErrors.unknownTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Base message keys for each error type.
|
||||
* Hoisted to module scope to avoid recreation on every function call.
|
||||
*/
|
||||
const BASE_MESSAGE_KEYS: Record<GitHubErrorType, string> = {
|
||||
rate_limit: 'githubErrors.rateLimitMessage',
|
||||
auth: 'githubErrors.authMessage',
|
||||
permission: 'githubErrors.permissionMessage',
|
||||
not_found: 'githubErrors.notFoundMessage',
|
||||
network: 'githubErrors.networkMessage',
|
||||
unknown: 'githubErrors.unknownMessage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Countdown time components for i18n-friendly formatting.
|
||||
*/
|
||||
interface CountdownComponents {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate countdown time components from reset time.
|
||||
* Returns numeric values for i18n-friendly formatting in the component.
|
||||
*/
|
||||
function getCountdownComponents(resetTime: Date): CountdownComponents | null {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
return {
|
||||
hours: diffHours,
|
||||
minutes: diffHours > 0 ? diffMins % 60 : diffMins,
|
||||
seconds: diffSecs % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most specific message key based on available metadata.
|
||||
* Pure function extracted to module scope to avoid recreation on each render.
|
||||
* @param info - The error info object
|
||||
* @param rateLimitDiffMs - Pre-computed time difference in milliseconds (avoids dual calculation)
|
||||
*/
|
||||
function getMessageKey(info: GitHubErrorInfo, rateLimitDiffMs?: number): string {
|
||||
if (info.type === 'rate_limit' && rateLimitDiffMs !== undefined && rateLimitDiffMs > 0) {
|
||||
const diffMins = Math.ceil(rateLimitDiffMs / 60000);
|
||||
return diffMins >= 60
|
||||
? 'githubErrors.rateLimitMessageHours'
|
||||
: 'githubErrors.rateLimitMessageMinutes';
|
||||
}
|
||||
if (info.type === 'permission' && info.requiredScopes && info.requiredScopes.length > 0) {
|
||||
return 'githubErrors.permissionMessageScopes';
|
||||
}
|
||||
return BASE_MESSAGE_KEYS[info.type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays GitHub API errors with appropriate icons,
|
||||
* messages, and action buttons based on error type.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With raw error string
|
||||
* <GitHubErrorDisplay
|
||||
* error="GitHub API error: 403 - Rate limit exceeded"
|
||||
* onRetry={handleRetry}
|
||||
* />
|
||||
*
|
||||
* // With pre-parsed error info
|
||||
* <GitHubErrorDisplay
|
||||
* error={errorInfo}
|
||||
* onOpenSettings={handleOpenSettings}
|
||||
* compact
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function GitHubErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
onOpenSettings,
|
||||
className,
|
||||
compact = false,
|
||||
}: GitHubErrorDisplayProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Parse error if it's a string, otherwise use the provided GitHubErrorInfo
|
||||
// Memoize to prevent useEffect churn from new Date references on each render
|
||||
const errorInfo: GitHubErrorInfo = useMemo(
|
||||
() =>
|
||||
typeof error === 'string' || error === null
|
||||
? parseGitHubError(error)
|
||||
: error,
|
||||
[error]
|
||||
);
|
||||
|
||||
// State for rate limit countdown components
|
||||
const [countdownComponents, setCountdownComponents] = useState<CountdownComponents | null>(() =>
|
||||
errorInfo.rateLimitResetTime
|
||||
? getCountdownComponents(errorInfo.rateLimitResetTime)
|
||||
: null
|
||||
);
|
||||
|
||||
// Update countdown every second for rate limit errors
|
||||
// Extract timestamp for stable useEffect dependency (avoids optional chaining in deps)
|
||||
const resetTimeMs = errorInfo.rateLimitResetTime?.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
if (errorInfo.type !== 'rate_limit' || !errorInfo.rateLimitResetTime) {
|
||||
// Clear stale countdown state when error type changes away from rate_limit
|
||||
setCountdownComponents(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetTime = errorInfo.rateLimitResetTime;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const components = getCountdownComponents(resetTime);
|
||||
setCountdownComponents(components);
|
||||
// Stop the interval when countdown expires
|
||||
if (!components && intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateCountdown();
|
||||
|
||||
// Only set interval if countdown is still active
|
||||
if (getCountdownComponents(resetTime)) {
|
||||
intervalId = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when error changes
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [errorInfo.type, resetTimeMs]);
|
||||
|
||||
// Format countdown using i18n
|
||||
const formatCountdownDisplay = (components: CountdownComponents | null): string => {
|
||||
if (!components) return '';
|
||||
if (components.hours > 0) {
|
||||
return t('githubErrors.countdownHoursMinutes', {
|
||||
hours: components.hours,
|
||||
minutes: components.minutes,
|
||||
});
|
||||
}
|
||||
return t('githubErrors.countdownMinutesSeconds', {
|
||||
minutes: components.minutes,
|
||||
seconds: components.seconds,
|
||||
});
|
||||
};
|
||||
|
||||
// Get configuration for this error type
|
||||
const config = ERROR_CONFIG[errorInfo.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
// Determine which actions to show
|
||||
const showRetry = ['rate_limit', 'network', 'unknown'].includes(errorInfo.type);
|
||||
const showSettings = ['auth', 'permission'].includes(errorInfo.type);
|
||||
const isRateLimitExpired =
|
||||
errorInfo.type === 'rate_limit' &&
|
||||
errorInfo.rateLimitResetTime &&
|
||||
new Date() >= errorInfo.rateLimitResetTime;
|
||||
|
||||
// Don't render if no error
|
||||
if (!error) return null;
|
||||
|
||||
// Compute time remaining once for both message key selection and translation
|
||||
const rateLimitDiffMs = errorInfo.rateLimitResetTime
|
||||
? errorInfo.rateLimitResetTime.getTime() - Date.now()
|
||||
: undefined;
|
||||
|
||||
// Get the translated message with appropriate interpolation values
|
||||
const messageKey = getMessageKey(errorInfo, rateLimitDiffMs);
|
||||
// Only pass positive minutes/hours values to avoid stale negative/zero values
|
||||
const rawMinutes = rateLimitDiffMs ? Math.ceil(rateLimitDiffMs / 60000) : undefined;
|
||||
const minutes = rawMinutes && rawMinutes > 0 ? rawMinutes : undefined;
|
||||
const hours = minutes ? Math.ceil(minutes / 60) : undefined;
|
||||
|
||||
const errorMessage = t(messageKey, {
|
||||
defaultValue: errorInfo.message,
|
||||
minutes,
|
||||
hours,
|
||||
scopes: errorInfo.requiredScopes?.join(', '),
|
||||
});
|
||||
|
||||
// Compact variant for inline display
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={errorMessage}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg bg-muted/50 border border-border',
|
||||
className
|
||||
)}
|
||||
title={errorMessage}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', config.iconColorClass)} />
|
||||
<span className="text-sm text-muted-foreground flex-1 truncate">
|
||||
{t(config.titleKey)}
|
||||
</span>
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenSettings}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Settings2 className="h-3 w-3 mr-1" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full card variant for blocking errors
|
||||
return (
|
||||
<Card role="alert" className={cn('border-destructive/50 m-4', className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Icon className={cn('h-6 w-6', config.iconColorClass)} />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t(config.titleKey)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
{/* Rate limit countdown display */}
|
||||
{errorInfo.type === 'rate_limit' && countdownComponents && (
|
||||
<p className="text-xs text-warning font-medium">
|
||||
{t('githubErrors.resetsIn', { time: formatCountdownDisplay(countdownComponents) })}
|
||||
</p>
|
||||
)}
|
||||
{/* Rate limit expired - show retry prompt */}
|
||||
{isRateLimitExpired && (
|
||||
<p className="text-xs text-primary">
|
||||
{t('githubErrors.rateLimitExpired')}
|
||||
</p>
|
||||
)}
|
||||
{/* Required scopes for permission errors */}
|
||||
{errorInfo.requiredScopes && errorInfo.requiredScopes.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('githubErrors.requiredScopes')}:{' '}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
{errorInfo.requiredScopes.join(', ')}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{showRetry && onRetry && (
|
||||
<Button onClick={onRetry} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button onClick={onOpenSettings} variant="outline" size="sm">
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { IssueListItem } from './IssueListItem';
|
||||
import { EmptyState } from './EmptyStates';
|
||||
import { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
import type { IssueListProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -16,9 +15,7 @@ export function IssueList({
|
||||
error,
|
||||
onSelectIssue,
|
||||
onInvestigate,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
onOpenSettings
|
||||
onLoadMore
|
||||
}: IssueListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -53,12 +50,12 @@ export function IssueList({
|
||||
// Load-more errors are shown inline near the load-more trigger
|
||||
if (error && issues.length === 0) {
|
||||
return (
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="p-4 bg-destructive/10 border-b border-destructive/30">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,18 +85,15 @@ export function IssueList({
|
||||
))}
|
||||
|
||||
{/* Load more trigger / Loading indicator */}
|
||||
{/* Inline error for load-more failures (visible even when onLoadMore is undefined during search) */}
|
||||
{error && issues.length > 0 && (
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
compact
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{onLoadMore && (
|
||||
<div ref={loadMoreTriggerRef} className="py-4 flex flex-col items-center gap-2">
|
||||
{/* Inline error for load-more failures (when issues are already loaded) */}
|
||||
{error && issues.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
-500
@@ -1,500 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/**
|
||||
* Unit tests for GitHubErrorDisplay component.
|
||||
* Tests error display, icon rendering, button visibility, and countdown functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { GitHubErrorDisplay } from '../GitHubErrorDisplay';
|
||||
import type { GitHubErrorInfo } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'githubErrors.rateLimitTitle': 'GitHub Rate Limit Reached',
|
||||
'githubErrors.authTitle': 'GitHub Authentication Required',
|
||||
'githubErrors.permissionTitle': 'GitHub Permission Denied',
|
||||
'githubErrors.notFoundTitle': 'GitHub Resource Not Found',
|
||||
'githubErrors.networkTitle': 'GitHub Connection Error',
|
||||
'githubErrors.unknownTitle': 'GitHub Error',
|
||||
'githubErrors.rateLimitMessage': 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
'githubErrors.rateLimitMessageMinutes': `GitHub API rate limit reached. Please wait ${options?.minutes ?? 'X'} minute(s) before trying again.`,
|
||||
'githubErrors.rateLimitMessageHours': `GitHub API rate limit reached. Rate limit resets in approximately ${options?.hours ?? 'X'} hour(s).`,
|
||||
'githubErrors.authMessage': 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
'githubErrors.permissionMessage': 'GitHub permission denied. Your token may not have the required access.',
|
||||
'githubErrors.permissionMessageScopes': `GitHub permission denied. Your token is missing required scopes: ${options?.scopes ?? ''}. Please update your GitHub token in Settings.`,
|
||||
'githubErrors.notFoundMessage': 'The requested GitHub resource was not found.',
|
||||
'githubErrors.networkMessage': 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
'githubErrors.unknownMessage': 'An unexpected error occurred while communicating with GitHub.',
|
||||
'githubErrors.resetsIn': options?.time ? `Resets in ${options.time as string}` : 'Resets in',
|
||||
'githubErrors.countdownHoursMinutes': `${options?.hours ?? 0}h ${options?.minutes ?? 0}m`,
|
||||
'githubErrors.countdownMinutesSeconds': `${options?.minutes ?? 0}m ${options?.seconds ?? 0}s`,
|
||||
'githubErrors.rateLimitExpired': 'Rate limit has reset. You can retry now.',
|
||||
'githubErrors.requiredScopes': 'Required scopes',
|
||||
'buttons.retry': 'Retry',
|
||||
'actions.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper to create mock GitHubErrorInfo
|
||||
function createMockErrorInfo(
|
||||
type: GitHubErrorInfo['type'],
|
||||
overrides: Partial<GitHubErrorInfo> = {}
|
||||
): GitHubErrorInfo {
|
||||
const defaults: Record<string, GitHubErrorInfo> = {
|
||||
rate_limit: {
|
||||
type: 'rate_limit',
|
||||
message: 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
statusCode: 403,
|
||||
},
|
||||
auth: {
|
||||
type: 'auth',
|
||||
message: 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
statusCode: 401,
|
||||
},
|
||||
permission: {
|
||||
type: 'permission',
|
||||
message: 'GitHub permission denied. Your token may not have the required access.',
|
||||
statusCode: 403,
|
||||
},
|
||||
not_found: {
|
||||
type: 'not_found',
|
||||
message: 'The requested GitHub resource was not found.',
|
||||
statusCode: 404,
|
||||
},
|
||||
network: {
|
||||
type: 'network',
|
||||
message: 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
},
|
||||
unknown: {
|
||||
type: 'unknown',
|
||||
message: 'An unexpected error occurred while communicating with GitHub.',
|
||||
},
|
||||
};
|
||||
|
||||
return { ...defaults[type], ...overrides };
|
||||
}
|
||||
|
||||
describe('GitHubErrorDisplay', () => {
|
||||
describe('rendering null/empty states', () => {
|
||||
it('should render nothing when error is null', () => {
|
||||
const { container } = render(<GitHubErrorDisplay error={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when error is an empty string', () => {
|
||||
// Empty string is falsy, so component should return null
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={'' as string} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with string error', () => {
|
||||
it('should render error display when error is a string', () => {
|
||||
render(<GitHubErrorDisplay error="401 Unauthorized" />);
|
||||
|
||||
// Should show the auth title (parsed from the error)
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error display for rate limit string error', () => {
|
||||
render(<GitHubErrorDisplay error="rate limit exceeded" />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with GitHubErrorInfo object', () => {
|
||||
it('should render rate_limit error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/GitHub API rate limit reached/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render auth error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/authentication failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render permission error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'workflow'],
|
||||
});
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Permission Denied')).toBeInTheDocument();
|
||||
// Check that permission message is rendered
|
||||
expect(screen.getByText(/Your token is missing required scopes/)).toBeInTheDocument();
|
||||
// Should show required scopes in the code element
|
||||
expect(screen.getByText('repo, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render not_found error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Resource Not Found')).toBeInTheDocument();
|
||||
expect(screen.getByText(/not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render network error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Connection Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unable to connect/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render unknown error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/unexpected error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode', () => {
|
||||
it('should render compact variant when compact=true', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
// In compact mode, the title is in a smaller span
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
// Should not render the card structure (no centered layout)
|
||||
expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button in compact mode for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button in compact mode for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full card mode (default)', () => {
|
||||
it('should render card structure by default', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should render heading
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for rate_limit errors with onRetry callback', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show retry button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for unknown errors', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for not_found errors', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show settings button for auth errors with onOpenSettings callback', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limit countdown', () => {
|
||||
it('should display countdown for rate limit errors with reset time', () => {
|
||||
// Set reset time 5 minutes in the future
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should show countdown in "Xm Ys" format (e.g., "4m 59s" or "5m 0s")
|
||||
expect(screen.getByText(/Resets in \d+m \d+s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should set up interval to update countdown', () => {
|
||||
vi.useFakeTimers();
|
||||
const resetTime = new Date(Date.now() + 2 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Initial countdown should be displayed
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Verify interval is running by checking timers
|
||||
const timerCount = vi.getTimerCount();
|
||||
expect(timerCount).toBe(1); // One interval should be running
|
||||
|
||||
// Advance time and verify interval still fires
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should NOT show countdown for non-rate-limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show rate limit expired message when reset time has passed', () => {
|
||||
// Set reset time in the past
|
||||
const resetTime = new Date(Date.now() - 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Rate limit has reset. You can retry now.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cleanup interval on unmount', () => {
|
||||
vi.useFakeTimers();
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
const { unmount } = render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Verify the countdown was rendered
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Unmount and verify clearInterval was called
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('required scopes display', () => {
|
||||
it('should display required scopes for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'read:org', 'workflow'],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('Required scopes:')).toBeInTheDocument();
|
||||
// The scopes appear in a code element
|
||||
expect(screen.getByText('repo, read:org, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when no scopes are provided', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: undefined,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when scopes array is empty', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: [],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should apply custom className in full card mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} className="custom-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should apply custom className in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} compact className="custom-compact-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-compact-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback stability', () => {
|
||||
it('should not call onRetry on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onOpenSettings on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(onOpenSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have role="alert" for screen reader announcements', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// The error card should have role="alert" for accessibility
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have role="alert" in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible button labels', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /retry/i });
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
});
|
||||
|
||||
it('should have accessible settings button label', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /settings/i });
|
||||
expect(button).toHaveTextContent('Settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,4 +6,3 @@ export { IssueListHeader } from './IssueListHeader';
|
||||
export { IssueList } from './IssueList';
|
||||
export { AutoFixButton } from './AutoFixButton';
|
||||
export { BatchReviewWizard } from './BatchReviewWizard';
|
||||
export { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
|
||||
@@ -3,47 +3,6 @@ import type { AutoFixConfig, AutoFixQueueItem } from '../../../../preload/api/mo
|
||||
|
||||
export type FilterState = 'open' | 'closed' | 'all';
|
||||
|
||||
/**
|
||||
* Classification types for GitHub API errors.
|
||||
* Used to determine appropriate icon, message, and actions for error display.
|
||||
*/
|
||||
export type GitHubErrorType =
|
||||
| 'rate_limit'
|
||||
| 'auth'
|
||||
| 'permission'
|
||||
| 'network'
|
||||
| 'not_found'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Parsed GitHub error information with metadata.
|
||||
* Returned by the github-error-parser utility.
|
||||
*
|
||||
* IMPORTANT: The `message` field contains hardcoded English strings intended
|
||||
* ONLY as a fallback defaultValue for i18n translation. Direct consumers should
|
||||
* use the `type` field to look up the appropriate translation key (e.g.,
|
||||
* 'githubErrors.rateLimitMessage') via react-i18next rather than displaying
|
||||
* `message` directly. This ensures proper localization for all users.
|
||||
*/
|
||||
export interface GitHubErrorInfo {
|
||||
/** The classified error type */
|
||||
type: GitHubErrorType;
|
||||
/**
|
||||
* User-friendly error message in English.
|
||||
* NOTE: Use only as defaultValue for i18n - do not display directly.
|
||||
* Use type field to look up translation key (e.g., 'githubErrors.rateLimitMessage').
|
||||
*/
|
||||
message: string;
|
||||
/** Original raw error string (for debugging/details) */
|
||||
rawMessage?: string;
|
||||
/** Rate limit reset time (only for rate_limit type) */
|
||||
rateLimitResetTime?: Date;
|
||||
/** Required OAuth scopes that are missing (only for permission type) */
|
||||
requiredScopes?: string[];
|
||||
/** HTTP status code if available */
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface GitHubIssuesProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** Navigate to view a task in the kanban board */
|
||||
@@ -117,10 +76,6 @@ export interface IssueListProps {
|
||||
onSelectIssue: (issueNumber: number) => void;
|
||||
onInvestigate: (issue: GitHubIssue) => void;
|
||||
onLoadMore?: () => void;
|
||||
/** Callback for retry button in error display */
|
||||
onRetry?: () => void;
|
||||
/** Callback for settings button in error display */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
|
||||
-691
@@ -1,691 +0,0 @@
|
||||
/**
|
||||
* Unit tests for GitHub API error parser utility.
|
||||
* Tests error classification, metadata extraction, and helper functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from '../github-error-parser';
|
||||
import type { GitHubErrorType } from '../../types';
|
||||
|
||||
describe('parseGitHubError', () => {
|
||||
describe('null/undefined/empty handling', () => {
|
||||
it('should return unknown for null input', () => {
|
||||
const result = parseGitHubError(null);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for undefined input', () => {
|
||||
const result = parseGitHubError(undefined);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for empty string', () => {
|
||||
const result = parseGitHubError('');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for whitespace-only string', () => {
|
||||
const result = parseGitHubError(' ');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate_limit errors', () => {
|
||||
it('should detect "rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('GitHub API error: rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "API rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('API rate limit exceeded for user');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "too many requests" pattern', () => {
|
||||
const result = parseGitHubError('Error: too many requests');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "403 rate limit" pattern', () => {
|
||||
const result = parseGitHubError('403 rate limit reached');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "abuse rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Abuse rate limit triggered');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "secondary rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Secondary rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from ISO date format', () => {
|
||||
const result = parseGitHubError('rate limit exceeded, resets at 2024-01-15T12:00:00Z');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
expect(result.rateLimitResetTime?.getUTCFullYear()).toBe(2024);
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from Unix timestamp', () => {
|
||||
const result = parseGitHubError('X-RateLimit-Reset: 1705312800');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with time remaining', () => {
|
||||
// Create a date 5 minutes in the future
|
||||
const futureDate = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const isoString = futureDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
});
|
||||
|
||||
it('should generate fallback message when reset time has passed', () => {
|
||||
// Create a date in the past
|
||||
const pastDate = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const isoString = pastDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('moment');
|
||||
});
|
||||
|
||||
it('should include raw message truncated to MAX_RAW_ERROR_LENGTH', () => {
|
||||
const longError = 'rate limit exceeded ' + 'x'.repeat(600);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rawMessage).toBeDefined();
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth errors', () => {
|
||||
it('should detect "401" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized');
|
||||
expect(result.type).toBe('auth');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', () => {
|
||||
const result = parseGitHubError('Error: unauthorized access');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "bad credentials" pattern', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "authentication failed" pattern', () => {
|
||||
const result = parseGitHubError('Authentication failed');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', () => {
|
||||
const result = parseGitHubError('Invalid token provided');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "token expired" pattern', () => {
|
||||
const result = parseGitHubError('Token expired');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', () => {
|
||||
const result = parseGitHubError('Not authenticated');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message mentioning Settings', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.message).toContain('authentication');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('not_found errors', () => {
|
||||
it('should detect "404" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should detect "not found" pattern', () => {
|
||||
const result = parseGitHubError('Repository not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "no such repository" pattern', () => {
|
||||
const result = parseGitHubError('No such repository exists');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "does not exist" pattern', () => {
|
||||
const result = parseGitHubError('Resource does not exist');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "user not found" pattern', () => {
|
||||
const result = parseGitHubError('User not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about verifying repository', () => {
|
||||
const result = parseGitHubError('404 Not Found');
|
||||
expect(result.message).toContain('not found');
|
||||
expect(result.message).toContain('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network errors', () => {
|
||||
it('should detect "network error" pattern', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "failed to fetch" pattern', () => {
|
||||
const result = parseGitHubError('Failed to fetch data');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNREFUSED" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNREFUSED');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNRESET" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNRESET');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ETIMEDOUT" pattern', () => {
|
||||
const result = parseGitHubError('Error: ETIMEDOUT');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection refused" pattern', () => {
|
||||
const result = parseGitHubError('Connection refused');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection timeout" pattern', () => {
|
||||
const result = parseGitHubError('Connection timeout');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "DNS error" pattern', () => {
|
||||
const result = parseGitHubError('DNS error occurred');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "offline" pattern', () => {
|
||||
const result = parseGitHubError('You are offline');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "no internet" pattern', () => {
|
||||
const result = parseGitHubError('No internet connection');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about internet connection', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.message).toContain('internet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission errors', () => {
|
||||
it('should detect "403" pattern (without rate limit context)', () => {
|
||||
const result = parseGitHubError('HTTP 403 Forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "forbidden" pattern', () => {
|
||||
const result = parseGitHubError('Access forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', () => {
|
||||
const result = parseGitHubError('Permission denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "insufficient scope" pattern', () => {
|
||||
const result = parseGitHubError('Insufficient scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', () => {
|
||||
const result = parseGitHubError('Access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "repository access denied" pattern', () => {
|
||||
const result = parseGitHubError('Repository access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "requires admin access" pattern', () => {
|
||||
const result = parseGitHubError('Requires admin access');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "missing required scope" pattern', () => {
|
||||
const result = parseGitHubError('Missing required scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should extract required scopes from error message with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, read:org');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('read:org');
|
||||
});
|
||||
|
||||
it('should extract scopes from "requires:" format with 403', () => {
|
||||
const result = parseGitHubError('403 - Requires: repo, workflow');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('workflow');
|
||||
});
|
||||
|
||||
it('should extract scopes from X-Accepted-OAuth-Scopes header with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden X-Accepted-OAuth-Scopes: repo');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, workflow');
|
||||
expect(result.message).toContain('repo');
|
||||
expect(result.message).toContain('workflow');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message without scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden');
|
||||
expect(result.message).toContain('permission');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown errors', () => {
|
||||
it('should return unknown for unrecognized error patterns', () => {
|
||||
const result = parseGitHubError('Something unexpected happened');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include raw message for unknown errors', () => {
|
||||
const result = parseGitHubError('Custom error message');
|
||||
expect(result.rawMessage).toBe('Custom error message');
|
||||
});
|
||||
|
||||
it('should extract status code even for unknown errors', () => {
|
||||
const result = parseGitHubError('HTTP 500 Internal Server Error');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error classification priority', () => {
|
||||
it('should prioritize rate_limit over permission (both 403)', () => {
|
||||
const result = parseGitHubError('403 rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should classify as permission when 403 without rate limit context', () => {
|
||||
const result = parseGitHubError('403 forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should handle errors with multiple patterns correctly', () => {
|
||||
// Rate limit should take priority
|
||||
const result = parseGitHubError('403 API rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should prioritize auth over not_found when both patterns present', () => {
|
||||
// "401" should be classified as auth, not not_found
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized - user not found');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should prioritize auth over network when 401 appears with network context', () => {
|
||||
const result = parseGitHubError('Network error: HTTP 401');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should classify as not_found when 404 without auth patterns', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should not match bare 401 in unrelated numbers', () => {
|
||||
// The word boundary should prevent matching "1401" as a 401 error
|
||||
const result = parseGitHubError('Error code 14010 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should not match bare 404 embedded in other numbers', () => {
|
||||
// The word boundary should prevent matching "404" embedded in "14040"
|
||||
const result = parseGitHubError('Error code 14040 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline error messages', () => {
|
||||
const result = parseGitHubError(`Error occurred:
|
||||
HTTP 401 Unauthorized
|
||||
Please check your credentials`);
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', () => {
|
||||
const testCases = [
|
||||
{ input: 'RATE LIMIT EXCEEDED', expected: 'rate_limit' as GitHubErrorType },
|
||||
{ input: 'UNAUTHORIZED', expected: 'auth' as GitHubErrorType },
|
||||
{ input: 'NOT FOUND', expected: 'not_found' as GitHubErrorType },
|
||||
{ input: 'NETWORK ERROR', expected: 'network' as GitHubErrorType },
|
||||
{ input: 'FORBIDDEN', expected: 'permission' as GitHubErrorType },
|
||||
];
|
||||
|
||||
for (const { input, expected } of testCases) {
|
||||
const result = parseGitHubError(input);
|
||||
expect(result.type).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle errors with JSON content', () => {
|
||||
const result = parseGitHubError('{"message":"Bad credentials","status":401}');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle errors with leading/trailing whitespace', () => {
|
||||
const result = parseGitHubError(' 401 Unauthorized ');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should sanitize very long error messages', () => {
|
||||
const longError = 'A'.repeat(1000);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503);
|
||||
expect(result.rawMessage).toContain('...');
|
||||
});
|
||||
|
||||
it('should not include rateLimitResetTime for non-rate-limit errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.rateLimitResetTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include requiredScopes for non-permission errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.requiredScopes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', () => {
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('API rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('too many requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', () => {
|
||||
expect(isRateLimitError('401 Unauthorized')).toBe(false);
|
||||
expect(isRateLimitError('404 Not Found')).toBe(false);
|
||||
expect(isRateLimitError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRateLimitError(null)).toBe(false);
|
||||
expect(isRateLimitError(undefined)).toBe(false);
|
||||
expect(isRateLimitError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isRateLimitError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(null, parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isRateLimitError('rate limit exceeded', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthError', () => {
|
||||
it('should return true for auth errors', () => {
|
||||
expect(isAuthError('401 Unauthorized')).toBe(true);
|
||||
expect(isAuthError('Bad credentials')).toBe(true);
|
||||
expect(isAuthError('Invalid token')).toBe(true);
|
||||
expect(isAuthError('Not authenticated')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth errors', () => {
|
||||
expect(isAuthError('rate limit exceeded')).toBe(false);
|
||||
expect(isAuthError('404 Not Found')).toBe(false);
|
||||
expect(isAuthError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isAuthError(null)).toBe(false);
|
||||
expect(isAuthError(undefined)).toBe(false);
|
||||
expect(isAuthError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isAuthError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isAuthError(null, parsedInfo)).toBe(true);
|
||||
expect(isAuthError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const rateLimitParsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isAuthError('401 Unauthorized', rateLimitParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNetworkError', () => {
|
||||
it('should return true for network errors', () => {
|
||||
expect(isNetworkError('Network error')).toBe(true);
|
||||
expect(isNetworkError('Failed to fetch')).toBe(true);
|
||||
expect(isNetworkError('ECONNREFUSED')).toBe(true);
|
||||
expect(isNetworkError('Connection timeout')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-network errors', () => {
|
||||
expect(isNetworkError('401 Unauthorized')).toBe(false);
|
||||
expect(isNetworkError('rate limit exceeded')).toBe(false);
|
||||
expect(isNetworkError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isNetworkError(null)).toBe(false);
|
||||
expect(isNetworkError(undefined)).toBe(false);
|
||||
expect(isNetworkError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'network' as const, message: 'test' };
|
||||
expect(isNetworkError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(null, parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isNetworkError('Network error', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRecoverableError', () => {
|
||||
it('should return true for recoverable errors (rate_limit, network, unknown)', () => {
|
||||
expect(isRecoverableError('rate limit exceeded')).toBe(true);
|
||||
expect(isRecoverableError('Network error')).toBe(true);
|
||||
expect(isRecoverableError('Unknown error occurred')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-recoverable errors (auth, permission, not_found)', () => {
|
||||
expect(isRecoverableError('401 Unauthorized')).toBe(false);
|
||||
expect(isRecoverableError('403 Forbidden')).toBe(false);
|
||||
expect(isRecoverableError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRecoverableError(null)).toBe(false);
|
||||
expect(isRecoverableError(undefined)).toBe(false);
|
||||
expect(isRecoverableError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const unknownInfo = { type: 'unknown' as const, message: 'test' };
|
||||
expect(isRecoverableError('unrelated error', rateLimitInfo)).toBe(true);
|
||||
expect(isRecoverableError(null, networkInfo)).toBe(true);
|
||||
expect(isRecoverableError(undefined, unknownInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type is non-recoverable', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionParsedInfo = { type: 'permission' as const, message: 'test' };
|
||||
const notFoundParsedInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(isRecoverableError('Network error', authParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('rate limit exceeded', permissionParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('unknown', notFoundParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresSettingsAction', () => {
|
||||
it('should return true for errors requiring settings action (auth, permission)', () => {
|
||||
expect(requiresSettingsAction('401 Unauthorized')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden')).toBe(true);
|
||||
expect(requiresSettingsAction('Invalid token')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden - missing scopes: repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for errors not requiring settings (rate_limit, network, not_found, unknown)', () => {
|
||||
expect(requiresSettingsAction('rate limit exceeded')).toBe(false);
|
||||
expect(requiresSettingsAction('Network error')).toBe(false);
|
||||
expect(requiresSettingsAction('404 Not Found')).toBe(false);
|
||||
expect(requiresSettingsAction('Unknown error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(requiresSettingsAction(null)).toBe(false);
|
||||
expect(requiresSettingsAction(undefined)).toBe(false);
|
||||
expect(requiresSettingsAction('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const authInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionInfo = { type: 'permission' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('unrelated error', authInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(null, permissionInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(undefined, authInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type does not require settings', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const notFoundInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('401 Unauthorized', rateLimitInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('403 Forbidden', networkInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('invalid token', notFoundInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-cutting concerns', () => {
|
||||
describe('consistency between parseGitHubError and helper functions', () => {
|
||||
it('should have consistent rate_limit detection', () => {
|
||||
const error = 'rate limit exceeded';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('rate_limit');
|
||||
expect(isRateLimitError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent auth detection', () => {
|
||||
const error = '401 Unauthorized';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('auth');
|
||||
expect(isAuthError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent network detection', () => {
|
||||
const error = 'Network error';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('network');
|
||||
expect(isNetworkError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent recoverable classification', () => {
|
||||
const errors = ['rate limit exceeded', 'Network error', 'Unknown error'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(isRecoverableError(error)).toBe(['rate_limit', 'network', 'unknown'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent settings action classification', () => {
|
||||
const errors = ['401 Unauthorized', '403 Forbidden'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(requiresSettingsAction(error)).toBe(['auth', 'permission'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusCode extraction', () => {
|
||||
it('should extract 403 for rate_limit errors', () => {
|
||||
const result = parseGitHubError('rate limit exceeded');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract 401 for auth errors', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should extract 404 for not_found errors', () => {
|
||||
const result = parseGitHubError('Not found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should extract 403 for permission errors', () => {
|
||||
const result = parseGitHubError('Forbidden');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract status code from message when present', () => {
|
||||
const result = parseGitHubError('HTTP 429 Too Many Requests');
|
||||
expect(result.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('should not extract invalid status codes', () => {
|
||||
const result = parseGitHubError('Error 999');
|
||||
expect(result.statusCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,497 +0,0 @@
|
||||
/**
|
||||
* GitHub API error parser utility.
|
||||
* Parses raw error strings to classify GitHub API errors and extract metadata.
|
||||
*/
|
||||
|
||||
import type { GitHubErrorType, GitHubErrorInfo } from '../types';
|
||||
|
||||
/**
|
||||
* Maximum length for raw error messages stored in GitHubErrorInfo.
|
||||
* Truncates to prevent memory bloat and UI issues.
|
||||
*/
|
||||
const MAX_RAW_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Patterns for rate limit errors (HTTP 403 with rate limit context).
|
||||
* Note: Pattern 1 covers all "rate limit" variations (api rate limit exceeded,
|
||||
* abuse rate limit, secondary rate limit, etc.) via substring matching.
|
||||
*/
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
/rate\s*limit/i, // Covers all variations containing "rate limit"
|
||||
/too\s*many\s*requests/i,
|
||||
/403.*rate/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for authentication errors (HTTP 401)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const AUTH_PATTERNS = [
|
||||
/unauthorized/i,
|
||||
/bad\s*credentials/i,
|
||||
/authentication\s*failed/i,
|
||||
/invalid\s*(oauth\s*)?token/i,
|
||||
/token\s*(is\s*)?(invalid|expired|required)/i,
|
||||
/not\s*authenticated/i,
|
||||
/requires\s*authentication/i, // GitHub 401 response body
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for permission/scope errors (HTTP 403 with scope context)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const PERMISSION_PATTERNS = [
|
||||
/forbidden/i,
|
||||
/permission\s*denied/i,
|
||||
/insufficient\s*(scope|permission)/i,
|
||||
/access\s*denied/i,
|
||||
/repository\s*access\s*denied/i,
|
||||
/not\s*authorized\s*to\s*access/i,
|
||||
/requires\s*(admin|write|read)\s*access/i,
|
||||
/missing\s*required\s*scope/i,
|
||||
// Matches "requires: repo" or "requires workflow" for OAuth scope context
|
||||
// Uses specific scope names to avoid matching "requires authentication" (auth error)
|
||||
/requires[:\s]+(?:repo|admin|write|read|workflow|org|gist|notification|user|project|package|delete|discussion)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for not found errors (HTTP 404)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives (e.g., "Issue #404").
|
||||
*/
|
||||
const NOT_FOUND_PATTERNS = [
|
||||
/not\s*found/i,
|
||||
/no\s*such\s*(repository|repo|issue|resource)/i,
|
||||
/does\s*not\s*exist/i,
|
||||
/repository\s*not\s*found/i,
|
||||
/user\s*not\s*found/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for network/connectivity errors
|
||||
*/
|
||||
const NETWORK_PATTERNS = [
|
||||
/network\s*(error|failed|unreachable)/i,
|
||||
/failed\s*to\s*fetch/i,
|
||||
/enetunreach/i,
|
||||
/econnrefused/i,
|
||||
/econnreset/i,
|
||||
/etimedout/i,
|
||||
/dns\s*(error|failed)/i,
|
||||
/offline/i,
|
||||
/no\s*internet/i,
|
||||
/unable\s*to\s*connect/i,
|
||||
/connection\s*(refused|reset|timeout|failed)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Pattern to extract required OAuth scopes from error messages
|
||||
* Matches formats like:
|
||||
* - "requires: repo, read:org"
|
||||
* - "missing scopes: repo, workflow"
|
||||
* - "X-Accepted-OAuth-Scopes: repo"
|
||||
* Stops at sentence boundaries or non-scope characters
|
||||
*/
|
||||
const REQUIRED_SCOPES_PATTERN = /(?:requires?[:\s]*|missing\s*scopes?[:\s]*|X-Accepted-OAuth-Scopes[:\s]*)([a-z0-9_:]+(?:[,\s]+[a-z0-9_:]+)*)/i;
|
||||
|
||||
/**
|
||||
* Pattern to extract HTTP status code from error messages.
|
||||
* Matches status codes preceded by HTTP context keywords or at string start
|
||||
* (for common error formats like "403 Forbidden").
|
||||
*/
|
||||
const STATUS_CODE_PATTERN = /(?:^|HTTP\s*|status[:\s]*|error[:\s]*|code[:\s]*)\b([1-5]\d{2})\b/i;
|
||||
|
||||
/**
|
||||
* Sanitize error output to a reasonable length.
|
||||
* Prevents memory bloat and UI issues from very long error messages.
|
||||
*/
|
||||
function sanitizeRawError(error: string): string {
|
||||
if (error.length > MAX_RAW_ERROR_LENGTH) {
|
||||
return error.substring(0, MAX_RAW_ERROR_LENGTH) + '...';
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasonable reset duration in seconds (24 hours).
|
||||
* Prevents malformed error strings from creating far-future dates.
|
||||
*/
|
||||
const MAX_RESET_SECONDS = 86400;
|
||||
|
||||
/**
|
||||
* Extract rate limit reset time from error message.
|
||||
* Parses various formats and returns a Date object if found.
|
||||
* Handles both absolute timestamps and relative durations ("in X seconds").
|
||||
*/
|
||||
function extractRateLimitResetTime(error: string): Date | undefined {
|
||||
// First, try to match relative duration pattern (e.g., "reset in 3600 seconds")
|
||||
const relativePattern = /reset[s]?\s*in[:\s]*(\d+)\s*seconds?/i;
|
||||
const relativeMatch = error.match(relativePattern);
|
||||
if (relativeMatch) {
|
||||
const seconds = parseInt(relativeMatch[1], 10);
|
||||
// Validate: positive, non-NaN, and within reasonable bounds (24 hours max)
|
||||
if (!Number.isNaN(seconds) && seconds > 0 && seconds <= MAX_RESET_SECONDS) {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Then try absolute timestamp pattern
|
||||
const absolutePattern = /(?:reset[s]?\s*at[:\s]*|X-RateLimit-Reset[:\s]*)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?|\d+)/i;
|
||||
const match = error.match(absolutePattern);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetValue = match[1].trim();
|
||||
|
||||
// Check if it's an ISO date string
|
||||
if (resetValue.includes('-') && resetValue.includes('T')) {
|
||||
const date = new Date(resetValue);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
// Check if it's a Unix timestamp (seconds or milliseconds)
|
||||
const numericValue = parseInt(resetValue, 10);
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
// GitHub API uses seconds, JavaScript uses milliseconds
|
||||
// Values > 1e12 are likely milliseconds already
|
||||
const timestamp = numericValue > 1e12 ? numericValue : numericValue * 1000;
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract required OAuth scopes from error message.
|
||||
* Returns an array of scope strings if found.
|
||||
*/
|
||||
function extractRequiredScopes(error: string): string[] | undefined {
|
||||
const match = error.match(REQUIRED_SCOPES_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopes = match[1]
|
||||
.split(/[,\s]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
return scopes.length > 0 ? scopes : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTTP status code from error message.
|
||||
*/
|
||||
function extractStatusCode(error: string): number | undefined {
|
||||
const match = error.match(STATUS_CODE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10);
|
||||
// Only return valid HTTP status codes
|
||||
if (code >= 100 && code < 600) {
|
||||
return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error matches any of the given patterns.
|
||||
*/
|
||||
function matchesPatterns(error: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some(pattern => pattern.test(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for rate limit errors.
|
||||
*/
|
||||
function getRateLimitMessage(_error: string, resetTime?: Date): string {
|
||||
if (resetTime) {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs > 0) {
|
||||
const diffMins = Math.ceil(diffMs / 60000);
|
||||
if (diffMins < 60) {
|
||||
return `GitHub API rate limit reached. Please wait ${diffMins} minute${diffMins !== 1 ? 's' : ''} before trying again.`;
|
||||
}
|
||||
const diffHours = Math.ceil(diffMins / 60);
|
||||
return `GitHub API rate limit reached. Rate limit resets in approximately ${diffHours} hour${diffHours !== 1 ? 's' : ''}.`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'GitHub API rate limit reached. Please wait a moment before trying again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for authentication errors.
|
||||
*/
|
||||
function getAuthMessage(): string {
|
||||
return 'GitHub authentication failed. Please check your GitHub token in Settings and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for permission errors.
|
||||
*/
|
||||
function getPermissionMessage(scopes?: string[]): string {
|
||||
if (scopes && scopes.length > 0) {
|
||||
return `GitHub permission denied. Your token is missing required scopes: ${scopes.join(', ')}. Please update your GitHub token in Settings.`;
|
||||
}
|
||||
return 'GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for not found errors.
|
||||
*/
|
||||
function getNotFoundMessage(): string {
|
||||
return 'The requested GitHub resource was not found. Please verify the repository exists and you have access to it.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for network errors.
|
||||
*/
|
||||
function getNetworkMessage(): string {
|
||||
return 'Unable to connect to GitHub. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for unknown errors.
|
||||
*/
|
||||
function getUnknownMessage(): string {
|
||||
return 'An unexpected error occurred while communicating with GitHub. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type based on pattern matching and optional status code.
|
||||
* Priority: rate_limit > auth > permission > not_found > network > unknown
|
||||
* Note: Permission checks run before not_found to properly classify 403 responses.
|
||||
* Status code fallback takes priority over network patterns since HTTP status
|
||||
* codes are more specific than generic network error text.
|
||||
* @param error - The error string to classify
|
||||
* @param statusCode - Optional HTTP status code extracted with context (helps classify when text patterns don't match)
|
||||
*/
|
||||
function classifyError(error: string, statusCode?: number): GitHubErrorType {
|
||||
// Check rate limit first (403 can also be permission, but rate limit is more specific)
|
||||
if (matchesPatterns(error, RATE_LIMIT_PATTERNS)) {
|
||||
return 'rate_limit';
|
||||
}
|
||||
|
||||
// Check auth (401 is always auth)
|
||||
if (matchesPatterns(error, AUTH_PATTERNS)) {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
// Check permission (403 without rate limit context) before not_found
|
||||
// to properly classify 403 responses that might contain "not found" text
|
||||
if (matchesPatterns(error, PERMISSION_PATTERNS)) {
|
||||
return 'permission';
|
||||
}
|
||||
|
||||
// Check not found (404 is always not_found)
|
||||
if (matchesPatterns(error, NOT_FOUND_PATTERNS)) {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
// Use status code fallback BEFORE network patterns
|
||||
// HTTP status codes are more specific than generic network error text
|
||||
if (statusCode === 401) return 'auth';
|
||||
if (statusCode === 403) return 'permission';
|
||||
if (statusCode === 404) return 'not_found';
|
||||
|
||||
// Check network errors (only if no status code fallback matched)
|
||||
if (matchesPatterns(error, NETWORK_PATTERNS)) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GitHub API error string and return classified error information.
|
||||
*
|
||||
* IMPORTANT: The returned `message` field contains hardcoded English strings
|
||||
* intended ONLY as a fallback defaultValue for i18n translation. Consumers
|
||||
* should use the `type` field to look up the appropriate translation key
|
||||
* (e.g., 'githubErrors.rateLimitMessage') via react-i18next rather than
|
||||
* displaying `message` directly. This ensures proper localization.
|
||||
*
|
||||
* Translation key mapping by type:
|
||||
* - rate_limit → 'githubErrors.rateLimitMessage' (or rateLimitMessageMinutes/Hours)
|
||||
* - auth → 'githubErrors.authMessage'
|
||||
* - permission → 'githubErrors.permissionMessage' (or permissionMessageScopes)
|
||||
* - not_found → 'githubErrors.notFoundMessage'
|
||||
* - network → 'githubErrors.networkMessage'
|
||||
* - unknown → 'githubErrors.unknownMessage'
|
||||
*
|
||||
* @param error - The raw error string (typically from issues-store error state)
|
||||
* @returns GitHubErrorInfo object with classified type, user-friendly message, and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const errorInfo = parseGitHubError('GitHub API error: 403 - API rate limit exceeded');
|
||||
* // Use type to get i18n key, message only as fallback:
|
||||
* // t(`githubErrors.${errorInfo.type}Message`, { defaultValue: errorInfo.message })
|
||||
* ```
|
||||
*/
|
||||
export function parseGitHubError(error: string | null | undefined): GitHubErrorInfo {
|
||||
// Handle null/undefined/empty errors
|
||||
if (!error || typeof error !== 'string' || error.trim() === '') {
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedError = error.trim();
|
||||
// Extract status code first so we can use it for classification fallback
|
||||
const statusCode = extractStatusCode(trimmedError);
|
||||
const errorType = classifyError(trimmedError, statusCode);
|
||||
|
||||
switch (errorType) {
|
||||
case 'rate_limit': {
|
||||
const resetTime = extractRateLimitResetTime(trimmedError);
|
||||
return {
|
||||
type: 'rate_limit',
|
||||
message: getRateLimitMessage(trimmedError, resetTime),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
rateLimitResetTime: resetTime,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'auth':
|
||||
return {
|
||||
type: 'auth',
|
||||
message: getAuthMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 401,
|
||||
};
|
||||
|
||||
case 'permission': {
|
||||
const scopes = extractRequiredScopes(trimmedError);
|
||||
return {
|
||||
type: 'permission',
|
||||
message: getPermissionMessage(scopes),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
requiredScopes: scopes,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'not_found':
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: getNotFoundMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 404,
|
||||
};
|
||||
|
||||
case 'network':
|
||||
return {
|
||||
type: 'network',
|
||||
message: getNetworkMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a rate limit error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRateLimitError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'rate_limit';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'rate_limit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isAuthError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'auth';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isNetworkError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'network';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'network';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable (user can retry).
|
||||
* Rate limit, network, and unknown errors are considered recoverable.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRecoverableError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['rate_limit', 'network', 'unknown'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['rate_limit', 'network', 'unknown'].includes(errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error requires user action in settings.
|
||||
* Auth and permission errors require settings changes.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function requiresSettingsAction(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['auth', 'permission'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['auth', 'permission'].includes(errorType);
|
||||
}
|
||||
@@ -19,13 +19,3 @@ export function filterIssuesBySearch(issues: GitHubIssue[], searchQuery: string)
|
||||
issue.body?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export GitHub error parser utilities
|
||||
export {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from './github-error-parser';
|
||||
|
||||
@@ -659,28 +659,6 @@
|
||||
"remote": "Remote"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "GitHub Rate Limit Reached",
|
||||
"authTitle": "GitHub Authentication Required",
|
||||
"permissionTitle": "GitHub Permission Denied",
|
||||
"notFoundTitle": "GitHub Resource Not Found",
|
||||
"networkTitle": "GitHub Connection Error",
|
||||
"unknownTitle": "GitHub Error",
|
||||
"rateLimitMessage": "GitHub API rate limit reached. Please wait a moment before trying again.",
|
||||
"rateLimitMessageMinutes": "GitHub API rate limit reached. Please wait {{minutes}} minute(s) before trying again.",
|
||||
"rateLimitMessageHours": "GitHub API rate limit reached. Rate limit resets in approximately {{hours}} hour(s).",
|
||||
"authMessage": "GitHub authentication failed. Please check your GitHub token in Settings and try again.",
|
||||
"permissionMessage": "GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.",
|
||||
"permissionMessageScopes": "GitHub permission denied. Your token is missing required scopes: {{scopes}}. Please update your GitHub token in Settings.",
|
||||
"notFoundMessage": "The requested GitHub resource was not found. Please verify the repository exists and you have access to it.",
|
||||
"networkMessage": "Unable to connect to GitHub. Please check your internet connection and try again.",
|
||||
"unknownMessage": "An unexpected error occurred while communicating with GitHub. Please try again.",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "Rate limit has reset. You can retry now.",
|
||||
"requiredScopes": "Required scopes"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Elapsed",
|
||||
"lastActivity": "Last activity",
|
||||
|
||||
@@ -659,28 +659,6 @@
|
||||
"remote": "Distante"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "Limite de débit GitHub atteinte",
|
||||
"authTitle": "Authentification GitHub requise",
|
||||
"permissionTitle": "Permission GitHub refusée",
|
||||
"notFoundTitle": "Ressource GitHub introuvable",
|
||||
"networkTitle": "Erreur de connexion GitHub",
|
||||
"unknownTitle": "Erreur GitHub",
|
||||
"rateLimitMessage": "Limite de débit de l'API GitHub atteinte. Veuillez patienter un moment avant de réessayer.",
|
||||
"rateLimitMessageMinutes": "Limite de débit de l'API GitHub atteinte. Veuillez attendre {{minutes}} minute(s) avant de réessayer.",
|
||||
"rateLimitMessageHours": "Limite de débit de l'API GitHub atteinte. La limite se réinitialise dans environ {{hours}} heure(s).",
|
||||
"authMessage": "Échec de l'authentification GitHub. Veuillez vérifier votre jeton GitHub dans les Paramètres et réessayer.",
|
||||
"permissionMessage": "Permission GitHub refusée. Votre jeton n'a peut-être pas les accès requis. Veuillez vérifier les permissions de votre jeton dans les Paramètres.",
|
||||
"permissionMessageScopes": "Permission GitHub refusée. Votre jeton manque de permissions requises : {{scopes}}. Veuillez mettre à jour votre jeton GitHub dans les Paramètres.",
|
||||
"notFoundMessage": "La ressource GitHub demandée est introuvable. Veuillez vérifier que le dépôt existe et que vous y avez accès.",
|
||||
"networkMessage": "Impossible de se connecter à GitHub. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"unknownMessage": "Une erreur inattendue s'est produite lors de la communication avec GitHub. Veuillez réessayer.",
|
||||
"resetsIn": "Réinitialisation dans {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "La limite de débit a été réinitialisée. Vous pouvez réessayer maintenant.",
|
||||
"requiredScopes": "Permissions requises"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Écoulé",
|
||||
"lastActivity": "Dernière activité",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude",
|
||||
"version": "2.7.6-beta.4",
|
||||
"version": "2.7.6-beta.3",
|
||||
"description": "Autonomous multi-agent coding framework powered by Claude AI",
|
||||
"license": "AGPL-3.0",
|
||||
"author": "Auto Claude Team",
|
||||
|
||||
+2
-9
@@ -66,13 +66,6 @@ _POTENTIALLY_MOCKED_MODULES = [
|
||||
'review',
|
||||
'validate_spec',
|
||||
'graphiti_providers',
|
||||
'agents.memory_manager',
|
||||
'agents.base',
|
||||
'core.error_utils',
|
||||
'security.tool_input_validator',
|
||||
'debug',
|
||||
'prompts_pkg',
|
||||
'prompts_pkg.project_context',
|
||||
]
|
||||
|
||||
# Store original module references at import time (before any mocking)
|
||||
@@ -120,8 +113,6 @@ def pytest_runtest_setup(item):
|
||||
'test_spec_pipeline': {'claude_code_sdk', 'claude_code_sdk.types', 'init', 'client', 'review', 'task_logger', 'ui', 'validate_spec'},
|
||||
'test_spec_complexity': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'},
|
||||
'test_spec_phases': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'graphiti_providers', 'validate_spec', 'client'},
|
||||
'test_qa_fixer': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client', 'agents.memory_manager', 'agents.base', 'core.error_utils', 'security.tool_input_validator', 'debug'},
|
||||
'test_qa_reviewer': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client', 'agents.memory_manager', 'agents.base', 'core.error_utils', 'security.tool_input_validator', 'debug', 'prompts_pkg', 'prompts_pkg.project_context'},
|
||||
}
|
||||
|
||||
# Get the mocks that the current test module needs to preserve
|
||||
@@ -166,6 +157,8 @@ def pytest_runtest_setup(item):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DIRECTORY FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared QA Test Helpers
|
||||
======================
|
||||
|
||||
Consolidates duplicated mock setup and utilities for test_qa_fixer.py and test_qa_reviewer.py.
|
||||
|
||||
This module provides:
|
||||
- AsyncIteratorMock: Async iterator mock for receive_response
|
||||
- ReceiveResponseMock: Smart wrapper supporting both .set_messages() and .return_value
|
||||
- setup_qa_mocks(): Module-level mock setup
|
||||
- cleanup_qa_mocks(): Module-level cleanup
|
||||
- reset_qa_mocks(): Reset shared mocks to default state
|
||||
- get_mock_*(): Accessor functions for mock objects
|
||||
- Mock response creation helpers
|
||||
- Shared pytest fixtures
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
# Add apps/backend to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ASYNC ITERATOR MOCKS
|
||||
# =============================================================================
|
||||
|
||||
class AsyncIteratorMock:
|
||||
"""Async iterator mock that yields stored messages and acts as async context manager."""
|
||||
|
||||
def __init__(self):
|
||||
self._messages = []
|
||||
self._index = 0
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._index >= len(self._messages):
|
||||
raise StopAsyncIteration
|
||||
msg = self._messages[self._index]
|
||||
self._index += 1
|
||||
return msg
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return False
|
||||
|
||||
def set_messages(self, messages):
|
||||
self._messages = messages
|
||||
self._index = 0
|
||||
|
||||
|
||||
class ReceiveResponseMock:
|
||||
"""Mock for receive_response that supports both .set_messages() and .return_value assignment."""
|
||||
|
||||
def __init__(self):
|
||||
self._iterator = AsyncIteratorMock()
|
||||
self.called = False # MagicMock compatibility
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.called = True
|
||||
return self._iterator
|
||||
|
||||
@property
|
||||
def return_value(self):
|
||||
return self._iterator
|
||||
|
||||
@return_value.setter
|
||||
def return_value(self, value):
|
||||
# When tests do mock_client.receive_response.return_value = list,
|
||||
# we set the messages on the iterator
|
||||
self._iterator.set_messages(value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE-LEVEL MOCKS
|
||||
# =============================================================================
|
||||
|
||||
# Store original modules for cleanup
|
||||
_original_modules = {}
|
||||
_mocked_module_names = [
|
||||
'claude_agent_sdk',
|
||||
'ui',
|
||||
'progress',
|
||||
'task_logger',
|
||||
'linear_updater',
|
||||
'client',
|
||||
'prompts_pkg',
|
||||
'prompts_pkg.project_context',
|
||||
'agents.memory_manager',
|
||||
'agents.base',
|
||||
'core.error_utils',
|
||||
'security.tool_input_validator',
|
||||
'debug',
|
||||
]
|
||||
|
||||
# Mock objects (initialized by setup_qa_mocks)
|
||||
_mock_state = {
|
||||
'sdk': None,
|
||||
'prompts_pkg': None,
|
||||
'project_context': None,
|
||||
'memory_manager': None,
|
||||
'agents_base': None,
|
||||
'error_utils': None,
|
||||
'validator': None,
|
||||
'debug': None,
|
||||
'ui': None,
|
||||
'progress': None,
|
||||
'task_logger': None,
|
||||
'linear': None,
|
||||
'client_module': None,
|
||||
'setup_done': False,
|
||||
'include_prompts_pkg': False, # Track what config was used
|
||||
}
|
||||
|
||||
|
||||
def get_mock_error_utils():
|
||||
"""Get the mock_error_utils object after setup."""
|
||||
return _mock_state['error_utils']
|
||||
|
||||
|
||||
def get_mock_memory_manager():
|
||||
"""Get the mock_memory_manager object after setup."""
|
||||
return _mock_state['memory_manager']
|
||||
|
||||
|
||||
def setup_qa_mocks(include_prompts_pkg: bool = False):
|
||||
"""Set up module-level mocks for QA tests.
|
||||
|
||||
Args:
|
||||
include_prompts_pkg: If True, mock prompts_pkg (needed for reviewer, not fixer)
|
||||
|
||||
Call this at module level before importing from qa modules.
|
||||
"""
|
||||
# Guard against redundant setup when called with same parameters
|
||||
# But allow prompts_pkg to be added if a later call needs it
|
||||
if _mock_state['setup_done']:
|
||||
# If prompts_pkg is already set up OR current call doesn't need it, skip
|
||||
if _mock_state['include_prompts_pkg'] or not include_prompts_pkg:
|
||||
return
|
||||
# Otherwise, we need to add prompts_pkg to existing setup
|
||||
# Fall through to only set up prompts_pkg below
|
||||
|
||||
# If setup is done but we need to add prompts_pkg, only do that part
|
||||
if _mock_state['setup_done'] and include_prompts_pkg and not _mock_state['include_prompts_pkg']:
|
||||
# Save originals before mocking
|
||||
for name in ['prompts_pkg', 'prompts_pkg.project_context']:
|
||||
if name in sys.modules and name not in _original_modules:
|
||||
_original_modules[name] = sys.modules[name]
|
||||
|
||||
# Only set up prompts_pkg
|
||||
mock_prompts_pkg = MagicMock()
|
||||
mock_prompts_pkg.get_qa_reviewer_prompt = MagicMock(return_value="Test QA prompt")
|
||||
sys.modules['prompts_pkg'] = mock_prompts_pkg
|
||||
_mock_state['prompts_pkg'] = mock_prompts_pkg
|
||||
mock_project_context = MagicMock()
|
||||
mock_prompts_pkg.project_context = mock_project_context
|
||||
sys.modules['prompts_pkg.project_context'] = mock_project_context
|
||||
_mock_state['project_context'] = mock_project_context
|
||||
_mock_state['include_prompts_pkg'] = True
|
||||
return
|
||||
|
||||
# Save originals for each module individually before mocking
|
||||
# This handles multiple setup calls with different parameters
|
||||
for name in _mocked_module_names:
|
||||
if name in sys.modules and name not in _original_modules:
|
||||
_original_modules[name] = sys.modules[name]
|
||||
|
||||
# Mock claude_agent_sdk FIRST
|
||||
mock_sdk = MagicMock()
|
||||
mock_sdk.ClaudeSDKClient = MagicMock()
|
||||
mock_sdk.ClaudeAgentOptions = MagicMock()
|
||||
mock_sdk.ClaudeCodeOptions = MagicMock()
|
||||
sys.modules['claude_agent_sdk'] = mock_sdk
|
||||
_mock_state['sdk'] = mock_sdk
|
||||
|
||||
# Mock prompts_pkg if needed
|
||||
if include_prompts_pkg:
|
||||
mock_prompts_pkg = MagicMock()
|
||||
mock_prompts_pkg.get_qa_reviewer_prompt = MagicMock(return_value="Test QA prompt")
|
||||
sys.modules['prompts_pkg'] = mock_prompts_pkg
|
||||
_mock_state['prompts_pkg'] = mock_prompts_pkg
|
||||
# Also mock prompts_pkg.project_context for imports in core/client.py
|
||||
mock_project_context = MagicMock()
|
||||
mock_prompts_pkg.project_context = mock_project_context
|
||||
sys.modules['prompts_pkg.project_context'] = mock_project_context
|
||||
_mock_state['project_context'] = mock_project_context
|
||||
|
||||
# Mock agents.memory_manager
|
||||
mock_memory_manager = MagicMock()
|
||||
mock_memory_manager.get_graphiti_context = AsyncMock(return_value=None)
|
||||
mock_memory_manager.save_session_memory = AsyncMock(return_value=None)
|
||||
sys.modules['agents.memory_manager'] = mock_memory_manager
|
||||
_mock_state['memory_manager'] = mock_memory_manager
|
||||
|
||||
# Mock agents.base
|
||||
mock_agents_base = MagicMock()
|
||||
mock_agents_base.sanitize_error_message = lambda x: x
|
||||
sys.modules['agents.base'] = mock_agents_base
|
||||
_mock_state['agents_base'] = mock_agents_base
|
||||
|
||||
# Mock core.error_utils
|
||||
mock_error_utils = MagicMock()
|
||||
mock_error_utils.is_rate_limit_error = MagicMock(return_value=False)
|
||||
mock_error_utils.is_tool_concurrency_error = MagicMock(return_value=False)
|
||||
sys.modules['core.error_utils'] = mock_error_utils
|
||||
_mock_state['error_utils'] = mock_error_utils
|
||||
|
||||
# Mock security.tool_input_validator
|
||||
mock_validator = MagicMock()
|
||||
mock_validator.get_safe_tool_input = lambda block: getattr(block, 'input', {})
|
||||
sys.modules['security.tool_input_validator'] = mock_validator
|
||||
_mock_state['validator'] = mock_validator
|
||||
|
||||
# Mock debug
|
||||
mock_debug = MagicMock()
|
||||
sys.modules['debug'] = mock_debug
|
||||
_mock_state['debug'] = mock_debug
|
||||
|
||||
# Mock UI module
|
||||
mock_ui = MagicMock()
|
||||
sys.modules['ui'] = mock_ui
|
||||
_mock_state['ui'] = mock_ui
|
||||
|
||||
# Mock progress module
|
||||
mock_progress = MagicMock()
|
||||
sys.modules['progress'] = mock_progress
|
||||
_mock_state['progress'] = mock_progress
|
||||
|
||||
# Mock task_logger
|
||||
mock_task_logger = MagicMock()
|
||||
mock_task_logger.LogPhase = MagicMock()
|
||||
mock_task_logger.LogEntryType = MagicMock()
|
||||
mock_task_logger.get_task_logger = MagicMock(return_value=None)
|
||||
sys.modules['task_logger'] = mock_task_logger
|
||||
_mock_state['task_logger'] = mock_task_logger
|
||||
|
||||
# Mock linear_updater
|
||||
mock_linear = MagicMock()
|
||||
sys.modules['linear_updater'] = mock_linear
|
||||
_mock_state['linear'] = mock_linear
|
||||
|
||||
# Mock client - create a factory that returns properly configured clients
|
||||
def _create_mock_client():
|
||||
"""Factory function that creates a properly configured mock client."""
|
||||
client = MagicMock()
|
||||
client.query = AsyncMock()
|
||||
client.receive_response = ReceiveResponseMock()
|
||||
return client
|
||||
|
||||
mock_client_module = MagicMock()
|
||||
mock_client_module.create_client = _create_mock_client
|
||||
sys.modules['client'] = mock_client_module
|
||||
_mock_state['client_module'] = mock_client_module
|
||||
_mock_state['setup_done'] = True
|
||||
_mock_state['include_prompts_pkg'] = include_prompts_pkg
|
||||
|
||||
|
||||
def cleanup_qa_mocks():
|
||||
"""Restore original modules after tests complete.
|
||||
|
||||
Call this in a module-scoped autouse fixture.
|
||||
"""
|
||||
for name in _mocked_module_names:
|
||||
if name in _original_modules:
|
||||
sys.modules[name] = _original_modules[name]
|
||||
elif name in sys.modules:
|
||||
del sys.modules[name]
|
||||
_mock_state['setup_done'] = False
|
||||
_mock_state['include_prompts_pkg'] = False
|
||||
# Note: We do NOT clear _original_modules here because:
|
||||
# 1. Multiple test modules may call cleanup, and clearing would break subsequent cleanups
|
||||
# 2. The 'if name not in _original_modules' guard in setup_qa_mocks prevents stale state
|
||||
# 3. Originals are saved per-module, so different setups can coexist
|
||||
|
||||
|
||||
def reset_qa_mocks():
|
||||
"""Reset shared mocks to default state.
|
||||
|
||||
Call this before and after each test to ensure isolation.
|
||||
"""
|
||||
mock_error_utils = _mock_state.get('error_utils')
|
||||
mock_memory_manager = _mock_state.get('memory_manager')
|
||||
|
||||
if mock_error_utils is not None:
|
||||
mock_error_utils.is_rate_limit_error.return_value = False
|
||||
mock_error_utils.is_tool_concurrency_error.return_value = False
|
||||
if mock_memory_manager is not None:
|
||||
mock_memory_manager.get_graphiti_context.reset_mock()
|
||||
mock_memory_manager.save_session_memory.reset_mock()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK RESPONSE HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def create_mock_response(text: str = "Session complete."):
|
||||
"""Create a standard mock assistant+user message pair.
|
||||
|
||||
Args:
|
||||
text: Text content for the AssistantMessage's TextBlock
|
||||
|
||||
Returns:
|
||||
List of mock messages [AssistantMessage, UserMessage]
|
||||
"""
|
||||
msg1 = MagicMock()
|
||||
msg1.__class__.__name__ = "AssistantMessage"
|
||||
text_block = MagicMock()
|
||||
text_block.__class__.__name__ = "TextBlock"
|
||||
text_block.text = text
|
||||
msg1.content = [text_block]
|
||||
|
||||
msg2 = MagicMock()
|
||||
msg2.__class__.__name__ = "UserMessage"
|
||||
msg2.content = []
|
||||
|
||||
return [msg1, msg2]
|
||||
|
||||
|
||||
def create_mock_fixed_response():
|
||||
"""Create mock response for fixed QA.
|
||||
|
||||
Returns:
|
||||
List of mock messages [AssistantMessage with 'Fixes applied successfully.', UserMessage]
|
||||
"""
|
||||
return create_mock_response("Fixes applied successfully.")
|
||||
|
||||
|
||||
def create_mock_tool_use_response(tool_name: str = "Bash", tool_input: dict = None):
|
||||
"""Create mock response with tool use.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool being used
|
||||
tool_input: Input dict for the tool
|
||||
|
||||
Returns:
|
||||
List of mock messages [AssistantMessage with ToolUseBlock, UserMessage]
|
||||
"""
|
||||
if tool_input is None:
|
||||
tool_input = {"command": "echo test"}
|
||||
|
||||
msg1 = MagicMock()
|
||||
msg1.__class__.__name__ = "AssistantMessage"
|
||||
tool_block = MagicMock()
|
||||
tool_block.__class__.__name__ = "ToolUseBlock"
|
||||
tool_block.name = tool_name
|
||||
tool_block.input = tool_input
|
||||
msg1.content = [tool_block]
|
||||
|
||||
msg2 = MagicMock()
|
||||
msg2.__class__.__name__ = "UserMessage"
|
||||
msg2.content = []
|
||||
|
||||
return [msg1, msg2]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURE HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def create_mock_client():
|
||||
"""Create a mock Claude SDK client for use in fixtures.
|
||||
|
||||
Returns:
|
||||
MagicMock configured as a Claude SDK client
|
||||
"""
|
||||
client = MagicMock()
|
||||
client.query = AsyncMock()
|
||||
client.receive_response = ReceiveResponseMock()
|
||||
return client
|
||||
@@ -22,7 +22,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Add apps/backend directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "backend"))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
|
||||
class TestNoExternalParallelism:
|
||||
@@ -31,7 +31,7 @@ class TestNoExternalParallelism:
|
||||
def test_no_coordinator_module(self):
|
||||
"""No external coordinator module should exist."""
|
||||
coordinator_path = (
|
||||
Path(__file__).parent.parent.parent / "apps" / "backend" / "coordinator.py"
|
||||
Path(__file__).parent.parent / "apps" / "backend" / "coordinator.py"
|
||||
)
|
||||
assert not coordinator_path.exists(), (
|
||||
"coordinator.py should not exist. Parallel orchestration is handled "
|
||||
@@ -41,7 +41,7 @@ class TestNoExternalParallelism:
|
||||
def test_no_task_tool_module(self):
|
||||
"""No task_tool wrapper module should exist."""
|
||||
task_tool_path = (
|
||||
Path(__file__).parent.parent.parent / "apps" / "backend" / "task_tool.py"
|
||||
Path(__file__).parent.parent / "apps" / "backend" / "task_tool.py"
|
||||
)
|
||||
assert not task_tool_path.exists(), (
|
||||
"task_tool.py should not exist. The agent spawns subagents directly "
|
||||
@@ -51,7 +51,7 @@ class TestNoExternalParallelism:
|
||||
def test_no_subtask_worker_config(self):
|
||||
"""No external subtask worker agent config should exist."""
|
||||
worker_config = (
|
||||
Path(__file__).parent.parent.parent / ".claude" / "agents" / "subtask-worker.md"
|
||||
Path(__file__).parent.parent / ".claude" / "agents" / "subtask-worker.md"
|
||||
)
|
||||
assert not worker_config.exists(), (
|
||||
"subtask-worker.md should not exist. Subagents use Claude Code's "
|
||||
@@ -64,7 +64,7 @@ class TestCLIInterface:
|
||||
|
||||
def test_no_parallel_flag(self):
|
||||
"""CLI should not have --parallel argument."""
|
||||
run_py_path = Path(__file__).parent.parent.parent / "apps" / "backend" / "run.py"
|
||||
run_py_path = Path(__file__).parent.parent / "apps" / "backend" / "run.py"
|
||||
content = run_py_path.read_text(encoding="utf-8")
|
||||
|
||||
# Check that --parallel is not defined as an argument
|
||||
@@ -79,7 +79,7 @@ class TestCLIInterface:
|
||||
|
||||
def test_no_parallel_examples_in_docs(self):
|
||||
"""CLI documentation should not mention parallel mode."""
|
||||
run_py_path = Path(__file__).parent.parent.parent / "apps" / "backend" / "run.py"
|
||||
run_py_path = Path(__file__).parent.parent / "apps" / "backend" / "run.py"
|
||||
content = run_py_path.read_text(encoding="utf-8")
|
||||
|
||||
# The docstring should not have --parallel examples
|
||||
@@ -132,7 +132,7 @@ class TestAgentPrompt:
|
||||
def test_mentions_subagents(self):
|
||||
"""Agent prompt mentions subagent capability."""
|
||||
coder_prompt_path = (
|
||||
Path(__file__).parent.parent.parent / "apps" / "backend" / "prompts" / "coder.md"
|
||||
Path(__file__).parent.parent / "apps" / "backend" / "prompts" / "coder.md"
|
||||
)
|
||||
content = coder_prompt_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -143,7 +143,7 @@ class TestAgentPrompt:
|
||||
def test_mentions_parallel_capability(self):
|
||||
"""Agent prompt mentions parallel/concurrent capability."""
|
||||
coder_prompt_path = (
|
||||
Path(__file__).parent.parent.parent / "apps" / "backend" / "prompts" / "coder.md"
|
||||
Path(__file__).parent.parent / "apps" / "backend" / "prompts" / "coder.md"
|
||||
)
|
||||
content = coder_prompt_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -170,7 +170,7 @@ class TestModuleIntegrity:
|
||||
|
||||
def test_run_module_valid_syntax(self):
|
||||
"""Run module has valid Python syntax."""
|
||||
run_py_path = Path(__file__).parent.parent.parent / "apps" / "backend" / "run.py"
|
||||
run_py_path = Path(__file__).parent.parent / "apps" / "backend" / "run.py"
|
||||
content = run_py_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
@@ -181,7 +181,7 @@ class TestModuleIntegrity:
|
||||
def test_no_coordinator_imports(self):
|
||||
"""Core modules don't import coordinator."""
|
||||
for filename in ["run.py", "core/agent.py"]:
|
||||
filepath = Path(__file__).parent.parent.parent / "apps" / "backend" / filename
|
||||
filepath = Path(__file__).parent.parent / "apps" / "backend" / filename
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
|
||||
assert "from coordinator import" not in content, (
|
||||
@@ -194,7 +194,7 @@ class TestModuleIntegrity:
|
||||
def test_no_task_tool_imports(self):
|
||||
"""Core modules don't import task_tool."""
|
||||
for filename in ["run.py", "core/agent.py"]:
|
||||
filepath = Path(__file__).parent.parent.parent / "apps" / "backend" / filename
|
||||
filepath = Path(__file__).parent.parent / "apps" / "backend" / filename
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
|
||||
assert "from task_tool import" not in content, (
|
||||
@@ -210,7 +210,7 @@ class TestProjectDocumentation:
|
||||
|
||||
def test_no_parallel_cli_documented(self):
|
||||
"""CLAUDE.md doesn't document --parallel flag."""
|
||||
claude_md_path = Path(__file__).parent.parent.parent / "CLAUDE.md"
|
||||
claude_md_path = Path(__file__).parent.parent / "CLAUDE.md"
|
||||
content = claude_md_path.read_text(encoding="utf-8")
|
||||
|
||||
assert "--parallel 2" not in content, (
|
||||
@@ -219,7 +219,7 @@ class TestProjectDocumentation:
|
||||
|
||||
def test_subagent_architecture_documented(self):
|
||||
"""CLAUDE.md documents subagent-based architecture."""
|
||||
claude_md_path = Path(__file__).parent.parent.parent / "CLAUDE.md"
|
||||
claude_md_path = Path(__file__).parent.parent / "CLAUDE.md"
|
||||
content = claude_md_path.read_text(encoding="utf-8")
|
||||
|
||||
has_subagent = "subagent" in content.lower()
|
||||
@@ -334,7 +334,7 @@ class TestSubtaskTerminology:
|
||||
def test_progress_uses_subtask_terminology(self):
|
||||
"""Progress module uses subtask terminology."""
|
||||
progress_path = (
|
||||
Path(__file__).parent.parent.parent / "apps" / "backend" / "core" / "progress.py"
|
||||
Path(__file__).parent.parent / "apps" / "backend" / "core" / "progress.py"
|
||||
)
|
||||
content = progress_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
backend_path = Path(__file__).parent.parent.parent / "apps" / "backend"
|
||||
backend_path = Path(__file__).parent.parent / "apps" / "backend"
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ Tests for planner→coder→QA state transitions including:
|
||||
Note: Uses temp_git_repo fixture from conftest.py for proper git isolation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -21,7 +22,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "backend"))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -189,7 +190,7 @@ class TestPlannerToCoderTransition:
|
||||
class TestPostSessionProcessing:
|
||||
"""Tests for post_session_processing function."""
|
||||
|
||||
async def test_completed_subtask_records_success(self, test_env):
|
||||
def test_completed_subtask_records_success(self, test_env):
|
||||
"""Test that completed subtask is recorded as successful."""
|
||||
from recovery import RecoveryManager
|
||||
from agents.session import post_session_processing
|
||||
@@ -211,16 +212,20 @@ class TestPostSessionProcessing:
|
||||
mock_insights.return_value = {"file_insights": [], "patterns_discovered": []}
|
||||
mock_memory.return_value = (True, "file")
|
||||
|
||||
result = await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
# Run async function using asyncio.run()
|
||||
async def run_test():
|
||||
return await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
|
||||
result = asyncio.run(run_test())
|
||||
|
||||
assert result is True, "Completed subtask should return True"
|
||||
|
||||
@@ -230,7 +235,7 @@ class TestPostSessionProcessing:
|
||||
assert history["attempts"][0]["success"] is True, "Attempt should be successful"
|
||||
assert history["status"] == "completed", "Status should be completed"
|
||||
|
||||
async def test_in_progress_subtask_records_failure(self, test_env):
|
||||
def test_in_progress_subtask_records_failure(self, test_env):
|
||||
"""Test that in_progress subtask is recorded as incomplete."""
|
||||
from recovery import RecoveryManager
|
||||
from agents.session import post_session_processing
|
||||
@@ -253,16 +258,20 @@ class TestPostSessionProcessing:
|
||||
mock_insights.return_value = {"file_insights": [], "patterns_discovered": []}
|
||||
mock_memory.return_value = (True, "file")
|
||||
|
||||
result = await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
# Run async function using asyncio.run()
|
||||
async def run_test():
|
||||
return await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
|
||||
result = asyncio.run(run_test())
|
||||
|
||||
assert result is False, "In-progress subtask should return False"
|
||||
|
||||
@@ -271,7 +280,7 @@ class TestPostSessionProcessing:
|
||||
assert len(history["attempts"]) == 1, "Should have 1 attempt"
|
||||
assert history["attempts"][0]["success"] is False, "Attempt should be unsuccessful"
|
||||
|
||||
async def test_pending_subtask_records_failure(self, test_env):
|
||||
def test_pending_subtask_records_failure(self, test_env):
|
||||
"""Test that pending (no progress) subtask is recorded as failure."""
|
||||
from recovery import RecoveryManager
|
||||
from agents.session import post_session_processing
|
||||
@@ -292,16 +301,20 @@ class TestPostSessionProcessing:
|
||||
mock_insights.return_value = {"file_insights": [], "patterns_discovered": []}
|
||||
mock_memory.return_value = (True, "file")
|
||||
|
||||
result = await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
# Run async function using asyncio.run()
|
||||
async def run_test():
|
||||
return await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id="subtask-1",
|
||||
session_num=1,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=1,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=False,
|
||||
)
|
||||
|
||||
result = asyncio.run(run_test())
|
||||
|
||||
assert result is False, "Pending subtask should return False"
|
||||
|
||||
@@ -13,7 +13,6 @@ Tests cover:
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -631,10 +630,9 @@ class TestEdgeCases:
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
|
||||
# Should not raise - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
result = discovery.discover(fake_dir)
|
||||
assert result is None
|
||||
# Should not raise
|
||||
result = discovery.discover(fake_dir)
|
||||
assert result is None
|
||||
|
||||
def test_ci_priority_github_first(self, discovery, temp_dir):
|
||||
"""Test that GitHub Actions takes priority."""
|
||||
|
||||
@@ -66,21 +66,16 @@ class TestDetectWorktreeIsolation:
|
||||
|
||||
def test_legacy_worktree_windows_path(self):
|
||||
"""Test detection of legacy worktree location on Windows."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = Path("C:/projects/x/.worktrees/009-audit")
|
||||
|
||||
# Mock resolve() to return a fixed path on Windows-style paths
|
||||
# since resolve() on Linux would prepend current working directory
|
||||
with patch.object(Path, 'resolve', return_value=Path("C:/projects/x/.worktrees/009-audit")):
|
||||
is_worktree, forbidden = detect_worktree_isolation(project_dir)
|
||||
is_worktree, forbidden = detect_worktree_isolation(project_dir)
|
||||
|
||||
assert is_worktree is True
|
||||
assert forbidden is not None
|
||||
# Check the essential parts
|
||||
norm_forbidden = normalize_path(str(forbidden))
|
||||
assert "projects" in norm_forbidden
|
||||
assert ".worktrees" not in norm_forbidden
|
||||
assert is_worktree is True
|
||||
assert forbidden is not None
|
||||
# Check the essential parts
|
||||
norm_forbidden = normalize_path(str(forbidden))
|
||||
assert "projects" in norm_forbidden
|
||||
assert ".worktrees" not in norm_forbidden
|
||||
|
||||
def test_pr_worktree_unix_path(self):
|
||||
"""Test detection of PR review worktree location on Unix-style path."""
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for QA Fixer Agent Session
|
||||
================================
|
||||
|
||||
Tests the qa/fixer.py module functionality including:
|
||||
- load_qa_fixer_prompt function
|
||||
- run_qa_fixer_session function
|
||||
- QA fixer session execution flow
|
||||
- Error handling and edge cases
|
||||
- Memory integration hooks
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# MOCK SETUP - Must happen before ANY imports from auto-claude
|
||||
# =============================================================================
|
||||
|
||||
# Import shared mock helpers
|
||||
from tests.qa_test_helpers import (
|
||||
setup_qa_mocks,
|
||||
cleanup_qa_mocks,
|
||||
reset_qa_mocks,
|
||||
create_mock_response,
|
||||
create_mock_fixed_response,
|
||||
create_mock_tool_use_response,
|
||||
create_mock_client,
|
||||
)
|
||||
|
||||
# Set up mocks (no prompts_pkg needed for fixer)
|
||||
setup_qa_mocks(include_prompts_pkg=False)
|
||||
|
||||
# Import after mocks are set up
|
||||
from qa.fixer import load_qa_fixer_prompt, run_qa_fixer_session
|
||||
from qa.criteria import save_implementation_plan
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def cleanup_mocked_modules():
|
||||
"""Restore original modules after all tests in this module complete."""
|
||||
yield
|
||||
cleanup_qa_mocks()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir(temp_dir):
|
||||
"""Create a spec directory with basic structure."""
|
||||
spec = temp_dir / "spec"
|
||||
spec.mkdir()
|
||||
return spec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a project directory."""
|
||||
project = temp_dir / "project"
|
||||
project.mkdir()
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock Claude SDK client."""
|
||||
return create_mock_client()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope='function')
|
||||
def reset_shared_mocks_before_test():
|
||||
"""Reset shared module-level mocks before and after each test."""
|
||||
reset_qa_mocks()
|
||||
yield
|
||||
reset_qa_mocks()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK RESPONSE HELPERS (fixer-specific)
|
||||
# =============================================================================
|
||||
|
||||
def _create_mock_response(text: str = "Fixer session complete."):
|
||||
"""Create a standard mock assistant+user message pair."""
|
||||
return create_mock_response(text)
|
||||
|
||||
|
||||
def _create_mock_fixed_response():
|
||||
"""Create mock response for fixed QA."""
|
||||
return create_mock_fixed_response()
|
||||
|
||||
|
||||
def _create_mock_tool_use_response():
|
||||
"""Create mock response with tool use blocks."""
|
||||
return create_mock_tool_use_response("Edit", {"file_path": "/test/file.py"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fix_request_file(spec_dir):
|
||||
"""Create a QA_FIX_REQUEST.md file."""
|
||||
fix_request = spec_dir / "QA_FIX_REQUEST.md"
|
||||
fix_request.write_text("# Fix Request\n\nFix the following issues:\n- Issue 1\n- Issue 2")
|
||||
return fix_request
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLoadQAFixerPrompt:
|
||||
"""Tests for load_qa_fixer_prompt function."""
|
||||
|
||||
def test_load_prompt_success(self, spec_dir, monkeypatch):
|
||||
"""Test successful prompt loading."""
|
||||
# Create prompts directory in temp location
|
||||
prompts_dir = spec_dir / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompt_file = prompts_dir / "qa_fixer.md"
|
||||
prompt_content = "# QA Fixer Prompt\n\nFix the issues..."
|
||||
prompt_file.write_text(prompt_content)
|
||||
|
||||
# Patch QA_PROMPTS_DIR to point to temp directory
|
||||
import qa.fixer as qa_fixer_module
|
||||
monkeypatch.setattr(qa_fixer_module, "QA_PROMPTS_DIR", prompts_dir)
|
||||
|
||||
result = load_qa_fixer_prompt()
|
||||
|
||||
assert result == prompt_content
|
||||
|
||||
def test_load_prompt_file_not_found(self, monkeypatch):
|
||||
"""Test FileNotFoundError when prompt file doesn't exist."""
|
||||
# Create an empty temp directory with no qa_fixer.md
|
||||
empty_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
try:
|
||||
# Patch QA_PROMPTS_DIR to point to empty directory
|
||||
import qa.fixer as qa_fixer_module
|
||||
monkeypatch.setattr(qa_fixer_module, "QA_PROMPTS_DIR", empty_dir)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_qa_fixer_prompt()
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
shutil.rmtree(empty_dir)
|
||||
|
||||
|
||||
class TestRunQAFixerSessionFixed:
|
||||
"""Tests for run_qa_fixer_session returning fixed status."""
|
||||
|
||||
async def test_fixed_status(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that fixed status is returned when ready_for_qa_revalidation is True."""
|
||||
# Setup implementation plan with ready_for_qa_revalidation
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "fixes_applied",
|
||||
"ready_for_qa_revalidation": True,
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_fixed_response())
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "fixed"
|
||||
assert len(result[1]) > 0 # Response text
|
||||
assert result[2] == {} # No error info
|
||||
|
||||
async def test_fixed_status_with_project_dir(self, mock_client, spec_dir, project_dir):
|
||||
"""Test session with explicit project_dir parameter."""
|
||||
# Create fix request file
|
||||
fix_request = spec_dir / "QA_FIX_REQUEST.md"
|
||||
fix_request.write_text("# Fix Request\n\nFix issues")
|
||||
|
||||
# Setup implementation plan
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "fixes_applied",
|
||||
"ready_for_qa_revalidation": True,
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_fixed_response())
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False,
|
||||
project_dir=project_dir
|
||||
)
|
||||
|
||||
assert result[0] == "fixed"
|
||||
|
||||
|
||||
class TestRunQAFixerSessionError:
|
||||
"""Tests for run_qa_fixer_session error handling."""
|
||||
|
||||
async def test_error_missing_fix_request(self, mock_client, spec_dir):
|
||||
"""Test error when QA_FIX_REQUEST.md is missing."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Don't create QA_FIX_REQUEST.md
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert "not found" in result[1].lower()
|
||||
assert result[2]["type"] == "other"
|
||||
assert result[2]["exception_type"] == "FileNotFoundError"
|
||||
|
||||
async def test_exception_handling(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test exception handling during fixer session."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Test exception")
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert "Test exception" in result[1] or "test exception" in result[1].lower()
|
||||
assert result[2]["type"] == "other"
|
||||
assert result[2]["exception_type"] == "Exception"
|
||||
|
||||
|
||||
class TestRunQAFixerSessionParameters:
|
||||
"""Tests for run_qa_fixer_session parameter handling."""
|
||||
|
||||
async def test_verbose_mode(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test session with verbose mode enabled."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_response())
|
||||
|
||||
await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Verify query was called
|
||||
assert mock_client.query.called
|
||||
|
||||
async def test_fix_session_number(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test session with different fix_session numbers."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_response())
|
||||
|
||||
await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
fix_session=3,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify query was called
|
||||
assert mock_client.query.called
|
||||
|
||||
|
||||
class TestRunQAFixerSessionIntegration:
|
||||
"""Integration tests for QA fixer session."""
|
||||
|
||||
async def test_full_session_flow(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test complete session flow from start to finish."""
|
||||
# Setup implementation plan
|
||||
plan = {
|
||||
"feature": "Test Feature",
|
||||
"qa_signoff": {
|
||||
"status": "fixes_applied",
|
||||
"ready_for_qa_revalidation": True,
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_response("Applying fixes..."))
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
fix_session=1,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result[0] == "fixed"
|
||||
assert mock_client.query.called
|
||||
assert mock_client.receive_response.called
|
||||
|
||||
|
||||
class TestMemoryIntegration:
|
||||
"""Tests for memory integration in QA fixer."""
|
||||
|
||||
async def test_memory_context_retrieval(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that memory context is retrieved during session."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_response())
|
||||
|
||||
# Patch where the function is used (in qa.fixer module)
|
||||
with patch('qa.fixer.get_graphiti_context', new_callable=AsyncMock) as mock_get_context:
|
||||
mock_get_context.return_value = "Past fix patterns: check imports"
|
||||
|
||||
await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify memory context was retrieved
|
||||
assert mock_get_context.called
|
||||
|
||||
async def test_memory_save_on_fixed(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that session memory is saved when fixes are applied."""
|
||||
# Setup implementation plan
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "fixes_applied",
|
||||
"ready_for_qa_revalidation": True,
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_fixed_response())
|
||||
|
||||
# Patch where the function is used
|
||||
with patch('qa.fixer.get_graphiti_context', new_callable=AsyncMock, return_value=None), \
|
||||
patch('qa.fixer.save_session_memory', new_callable=AsyncMock) as mock_save:
|
||||
|
||||
await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify memory was saved
|
||||
assert mock_save.called
|
||||
|
||||
|
||||
class TestErrorDetection:
|
||||
"""Tests for error type detection in QA fixer."""
|
||||
|
||||
async def test_rate_limit_error_detection(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that rate limit errors are properly detected."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Rate limit exceeded")
|
||||
|
||||
# Patch where the functions are used (qa.fixer) not where they're defined
|
||||
with patch('qa.fixer.is_rate_limit_error', return_value=True), \
|
||||
patch('qa.fixer.is_tool_concurrency_error', return_value=False):
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert result[2]["type"] == "rate_limit"
|
||||
|
||||
async def test_tool_concurrency_error_detection(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that tool concurrency errors are properly detected."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Tool concurrency limit")
|
||||
|
||||
# Patch where the functions are used (qa.fixer) not where they're defined
|
||||
with patch('qa.fixer.is_tool_concurrency_error', return_value=True), \
|
||||
patch('qa.fixer.is_rate_limit_error', return_value=False), \
|
||||
patch('qa.fixer.get_graphiti_context', new_callable=AsyncMock, return_value=None):
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert result[2]["type"] == "tool_concurrency"
|
||||
|
||||
|
||||
class TestStatusNotUpdated:
|
||||
"""Tests for when fixer doesn't update status."""
|
||||
|
||||
async def test_fixed_assumed_when_status_not_updated(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that fixed is assumed even when status not updated."""
|
||||
# Setup implementation plan without ready_for_qa_revalidation
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_response())
|
||||
|
||||
# Patch where the function is used
|
||||
with patch('qa.fixer.get_graphiti_context', new_callable=AsyncMock, return_value=None), \
|
||||
patch('qa.fixer.save_session_memory', new_callable=AsyncMock) as mock_save:
|
||||
|
||||
result = await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
# Should still return "fixed" even though status wasn't updated
|
||||
assert result[0] == "fixed"
|
||||
# Memory should still be saved
|
||||
assert mock_save.called
|
||||
|
||||
|
||||
class TestToolUseHandling:
|
||||
"""Tests for tool use handling in QA fixer."""
|
||||
|
||||
async def test_tool_use_blocks(self, mock_client, spec_dir, fix_request_file):
|
||||
"""Test that tool use blocks are handled correctly."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses with tool use
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value.set_messages(_create_mock_tool_use_response())
|
||||
|
||||
await run_qa_fixer_session(
|
||||
mock_client,
|
||||
spec_dir,
|
||||
1,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify query was called
|
||||
assert mock_client.query.called
|
||||
@@ -1,506 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for QA Reviewer Agent Session
|
||||
===================================
|
||||
|
||||
Tests the qa/reviewer.py module functionality including:
|
||||
- run_qa_agent_session function
|
||||
- QA session execution flow
|
||||
- Error handling and edge cases
|
||||
- Memory integration hooks
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# MOCK SETUP - Must happen before ANY imports from auto-claude
|
||||
# =============================================================================
|
||||
|
||||
# Import shared mock helpers
|
||||
from tests.qa_test_helpers import (
|
||||
setup_qa_mocks,
|
||||
cleanup_qa_mocks,
|
||||
reset_qa_mocks,
|
||||
create_mock_response,
|
||||
create_mock_client,
|
||||
)
|
||||
|
||||
# Set up mocks (reviewer needs prompts_pkg)
|
||||
setup_qa_mocks(include_prompts_pkg=True)
|
||||
|
||||
# Import after mocks are set up
|
||||
from qa.reviewer import run_qa_agent_session
|
||||
from qa.criteria import save_implementation_plan
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def cleanup_mocked_modules():
|
||||
"""Restore original modules after all tests in this module complete."""
|
||||
yield
|
||||
cleanup_qa_mocks()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir(temp_dir):
|
||||
"""Create a spec directory with basic structure."""
|
||||
spec = temp_dir / "spec"
|
||||
spec.mkdir()
|
||||
return spec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a project directory."""
|
||||
project = temp_dir / "project"
|
||||
project.mkdir()
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock Claude SDK client."""
|
||||
return create_mock_client()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope='function')
|
||||
def reset_shared_mocks_before_test():
|
||||
"""Reset shared module-level mocks before and after each test."""
|
||||
reset_qa_mocks()
|
||||
yield
|
||||
reset_qa_mocks()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK RESPONSE HELPERS (reviewer-specific)
|
||||
# =============================================================================
|
||||
|
||||
def _create_approved_response():
|
||||
"""Create mock response for approved QA."""
|
||||
return create_mock_response("QA approved - all criteria met.")
|
||||
|
||||
|
||||
def _create_rejected_response():
|
||||
"""Create mock response for rejected QA."""
|
||||
return create_mock_response("QA rejected - found issues.")
|
||||
|
||||
|
||||
def _create_no_signoff_response():
|
||||
"""Create mock response where agent doesn't update signoff."""
|
||||
return create_mock_response("QA review complete.")
|
||||
|
||||
|
||||
def _create_tool_use_response():
|
||||
"""Create mock response with tool use blocks."""
|
||||
msg1, msg2 = create_mock_response("Checking files...")
|
||||
# Add tool use block to first message
|
||||
from unittest.mock import MagicMock
|
||||
tool_block = MagicMock()
|
||||
tool_block.__class__.__name__ = "ToolUseBlock"
|
||||
tool_block.name = "Read"
|
||||
tool_block.input = {"file_path": "/test/file.py"}
|
||||
msg1.content.append(tool_block)
|
||||
|
||||
return [msg1, msg2]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRunQAAgentSessionApproved:
|
||||
"""Tests for run_qa_agent_session returning approved status."""
|
||||
|
||||
async def test_approved_status(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that approved status is returned correctly."""
|
||||
# Setup implementation plan with approved status
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "approved",
|
||||
"qa_session": 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_approved_response()
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "approved"
|
||||
assert len(result[1]) > 0 # Response text
|
||||
assert result[2] == {} # No error info
|
||||
|
||||
|
||||
class TestRunQAAgentSessionRejected:
|
||||
"""Tests for run_qa_agent_session returning rejected status."""
|
||||
|
||||
async def test_rejected_status(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that rejected status is returned correctly."""
|
||||
# Setup implementation plan with rejected status
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "rejected",
|
||||
"qa_session": 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"issues_found": [
|
||||
{"title": "Test failure", "type": "unit_test"},
|
||||
]
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_rejected_response()
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "rejected"
|
||||
assert len(result[1]) > 0 # Response text
|
||||
assert result[2] == {} # No error info
|
||||
|
||||
|
||||
class TestRunQAAgentSessionError:
|
||||
"""Tests for run_qa_agent_session error handling."""
|
||||
|
||||
async def test_error_status_no_signoff(self, mock_client, spec_dir, project_dir):
|
||||
"""Test error status when agent doesn't update signoff."""
|
||||
# Setup implementation plan without qa_signoff
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses - agent doesn't update signoff
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_no_signoff_response()
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert "did not update" in result[1].lower()
|
||||
assert result[2]["type"] == "other"
|
||||
|
||||
async def test_exception_handling(self, mock_client, spec_dir, project_dir):
|
||||
"""Test exception handling during QA session."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Test exception")
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert "Test exception" in result[1] or "test exception" in result[1].lower()
|
||||
assert result[2]["type"] == "other"
|
||||
assert result[2]["exception_type"] == "Exception"
|
||||
|
||||
|
||||
class TestRunQAAgentSessionParameters:
|
||||
"""Tests for run_qa_agent_session parameter handling."""
|
||||
|
||||
async def test_with_previous_error(self, mock_client, spec_dir, project_dir):
|
||||
"""Test session with previous error context."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
previous_error = {
|
||||
"error_type": "missing_implementation_plan_update",
|
||||
"error_message": "Test error",
|
||||
"consecutive_errors": 2,
|
||||
}
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_no_signoff_response()
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False,
|
||||
previous_error=previous_error
|
||||
)
|
||||
|
||||
# Verify query was called (it should include error context)
|
||||
assert mock_client.query.called
|
||||
|
||||
async def test_verbose_mode(self, mock_client, spec_dir, project_dir):
|
||||
"""Test session with verbose mode enabled."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_no_signoff_response()
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Verify query was called
|
||||
assert mock_client.query.called
|
||||
|
||||
|
||||
class TestRunQAAgentSessionIntegration:
|
||||
"""Integration tests for QA reviewer session."""
|
||||
|
||||
async def test_full_session_flow(self, mock_client, spec_dir, project_dir):
|
||||
"""Test complete session flow from start to finish."""
|
||||
# Setup implementation plan
|
||||
plan = {
|
||||
"feature": "Test Feature",
|
||||
"qa_signoff": {
|
||||
"status": "approved",
|
||||
"qa_session": 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"tests_passed": {"unit": True, "integration": True},
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_approved_response()
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
qa_session=1,
|
||||
max_iterations=50,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result[0] == "approved"
|
||||
assert mock_client.query.called
|
||||
assert mock_client.receive_response.called
|
||||
|
||||
|
||||
class TestMemoryIntegration:
|
||||
"""Tests for memory integration in QA reviewer."""
|
||||
|
||||
async def test_memory_context_retrieval(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that memory context is retrieved during session."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_no_signoff_response()
|
||||
|
||||
# Patch where the function is used (in qa.reviewer module)
|
||||
with patch('qa.reviewer.get_graphiti_context', new_callable=AsyncMock) as mock_get_context:
|
||||
mock_get_context.return_value = "Past QA insights: check for edge cases"
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify memory context was retrieved
|
||||
assert mock_get_context.called
|
||||
|
||||
async def test_memory_save_on_approved(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that session memory is saved on approval."""
|
||||
# Setup implementation plan with approved status
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "approved",
|
||||
"qa_session": 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_approved_response()
|
||||
|
||||
# Patch where the functions are used
|
||||
with patch('qa.reviewer.get_graphiti_context', new_callable=AsyncMock, return_value=None), \
|
||||
patch('qa.reviewer.save_session_memory', new_callable=AsyncMock) as mock_save:
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify memory was saved
|
||||
assert mock_save.called
|
||||
|
||||
async def test_memory_save_on_rejected(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that session memory is saved on rejection with issues."""
|
||||
# Setup implementation plan with rejected status
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
"qa_signoff": {
|
||||
"status": "rejected",
|
||||
"qa_session": 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"issues_found": [
|
||||
{"title": "Test failure", "type": "unit_test"},
|
||||
]
|
||||
}
|
||||
}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_rejected_response()
|
||||
|
||||
# Patch where the functions are used
|
||||
with patch('qa.reviewer.get_graphiti_context', new_callable=AsyncMock, return_value=None), \
|
||||
patch('qa.reviewer.save_session_memory', new_callable=AsyncMock) as mock_save:
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify memory was saved with issues
|
||||
assert mock_save.called
|
||||
|
||||
|
||||
class TestErrorDetection:
|
||||
"""Tests for error type detection in QA reviewer."""
|
||||
|
||||
async def test_rate_limit_error_detection(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that rate limit errors are properly detected."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Rate limit exceeded")
|
||||
|
||||
# Patch where the functions are used (qa.reviewer) not where they're defined
|
||||
with patch('qa.reviewer.is_rate_limit_error', return_value=True), \
|
||||
patch('qa.reviewer.is_tool_concurrency_error', return_value=False):
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert result[2]["type"] == "rate_limit"
|
||||
|
||||
async def test_tool_concurrency_error_detection(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that tool concurrency errors are properly detected."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client to raise exception
|
||||
mock_client.query.side_effect = Exception("Tool concurrency limit")
|
||||
|
||||
# Patch where the functions are used
|
||||
with patch('qa.reviewer.is_tool_concurrency_error', return_value=True), \
|
||||
patch('qa.reviewer.is_rate_limit_error', return_value=False):
|
||||
|
||||
result = await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
assert result[0] == "error"
|
||||
assert result[2]["type"] == "tool_concurrency"
|
||||
|
||||
|
||||
class TestToolUseHandling:
|
||||
"""Tests for tool use handling in QA reviewer."""
|
||||
|
||||
async def test_tool_use_blocks(self, mock_client, spec_dir, project_dir):
|
||||
"""Test that tool use blocks are handled correctly."""
|
||||
# Setup implementation plan
|
||||
plan = {"feature": "Test"}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Mock client responses with tool use
|
||||
mock_client.query.return_value = None
|
||||
mock_client.receive_response.return_value = _create_tool_use_response()
|
||||
|
||||
await run_qa_agent_session(
|
||||
mock_client,
|
||||
project_dir,
|
||||
spec_dir,
|
||||
1,
|
||||
50,
|
||||
False
|
||||
)
|
||||
|
||||
# Verify query was called
|
||||
assert mock_client.query.called
|
||||
@@ -384,10 +384,9 @@ class TestEdgeCases:
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
|
||||
# Should not crash, may have errors - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
result = scanner.scan(fake_dir)
|
||||
assert isinstance(result, SecurityScanResult)
|
||||
# Should not crash, may have errors
|
||||
result = scanner.scan(fake_dir)
|
||||
assert isinstance(result, SecurityScanResult)
|
||||
|
||||
def test_scan_specific_files(self, scanner, python_project):
|
||||
"""Test scanning specific files only."""
|
||||
|
||||
@@ -12,7 +12,6 @@ Tests cover:
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -440,10 +439,9 @@ class TestEdgeCases:
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
|
||||
# Should not crash - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
orchestrator = ServiceOrchestrator(fake_dir)
|
||||
assert orchestrator.is_multi_service() is False
|
||||
# Should not crash
|
||||
orchestrator = ServiceOrchestrator(fake_dir)
|
||||
assert orchestrator.is_multi_service() is False
|
||||
|
||||
def test_empty_compose_file(self, temp_dir):
|
||||
"""Test handling of empty compose file."""
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for spec/validate_pkg/validators/context_validator.py
|
||||
============================================================
|
||||
|
||||
Tests for ContextValidator class covering:
|
||||
- File existence checks
|
||||
- JSON parsing validation
|
||||
- Required field validation
|
||||
- Recommended field warnings
|
||||
- ValidationResult return values
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestContextValidatorInit:
|
||||
"""Tests for ContextValidator initialization."""
|
||||
|
||||
def test_initialization_with_path(self, spec_dir: Path):
|
||||
"""ContextValidator initializes with spec_dir path."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
|
||||
assert validator.spec_dir == spec_dir
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
|
||||
def test_converts_string_to_path(self, spec_dir: Path):
|
||||
"""ContextValidator converts string path to Path object."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
validator = ContextValidator(str(spec_dir))
|
||||
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
assert validator.spec_dir == spec_dir
|
||||
|
||||
|
||||
class TestValidateFileNotFound:
|
||||
"""Tests for validate() when context.json does not exist."""
|
||||
|
||||
def test_returns_error_when_file_missing(self, spec_dir: Path):
|
||||
"""Should return ValidationResult with error when context.json missing."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert result.checkpoint == "context"
|
||||
assert any("not found" in err.lower() for err in result.errors)
|
||||
assert len(result.fixes) > 0
|
||||
|
||||
def test_error_message_includes_filename(self, spec_dir: Path):
|
||||
"""Error message should mention context.json."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert "context.json" in result.errors[0]
|
||||
|
||||
def test_fix_suggests_command(self, spec_dir: Path):
|
||||
"""Suggested fix should include the context.py command."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("auto-claude/context.py" in fix for fix in result.fixes)
|
||||
assert any("--output context.json" in fix for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateInvalidJson:
|
||||
"""Tests for validate() with invalid JSON content."""
|
||||
|
||||
def test_returns_error_for_invalid_json(self, spec_dir: Path):
|
||||
"""Should return error when context.json has invalid JSON."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text("{invalid json content", encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert result.checkpoint == "context"
|
||||
assert any("invalid json" in err.lower() for err in result.errors)
|
||||
|
||||
def test_error_includes_json_parse_message(self, spec_dir: Path):
|
||||
"""Error message should include JSON parsing error details."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"unclosed": true', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Error message should mention the JSON decode error
|
||||
assert any("json" in err.lower() for err in result.errors)
|
||||
|
||||
def test_fix_suggests_regenerate(self, spec_dir: Path):
|
||||
"""Suggested fix should mention regenerating context.json."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text("{bad}", encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("regenerate" in fix.lower() or "fix" in fix.lower() for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateMissingRequiredFields:
|
||||
"""Tests for validate() with missing required fields."""
|
||||
|
||||
def test_error_when_task_description_missing(self, spec_dir: Path):
|
||||
"""Should error when required field 'task_description' is missing."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"other_field": "value"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert any("task_description" in err for err in result.errors)
|
||||
|
||||
def test_error_for_all_required_fields_missing(self, spec_dir: Path):
|
||||
"""Should list all missing required fields."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
from spec.validate_pkg.schemas import CONTEXT_SCHEMA
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Check that all required fields are mentioned in errors
|
||||
required_fields = CONTEXT_SCHEMA["required_fields"]
|
||||
for field in required_fields:
|
||||
assert any(field in err for err in result.errors), f"Field {field} not in errors"
|
||||
|
||||
def test_fixes_suggest_adding_missing_fields(self, spec_dir: Path):
|
||||
"""Suggested fixes should include adding missing fields."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"created_at": "2024-01-01"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Fixes should suggest adding task_description
|
||||
assert any("task_description" in fix for fix in result.fixes)
|
||||
|
||||
def test_valid_when_all_required_fields_present(self, spec_dir: Path):
|
||||
"""Should pass validation when all required fields exist."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {"task_description": "Add user authentication"}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
class TestValidateRecommendedFields:
|
||||
"""Tests for validate() recommended field warnings."""
|
||||
|
||||
def test_warns_when_files_to_modify_missing(self, spec_dir: Path):
|
||||
"""Should warn when 'files_to_modify' is missing."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {"task_description": "Test task"}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Missing recommended field should be a warning, not error
|
||||
assert any("files_to_modify" in warn for warn in result.warnings)
|
||||
assert all("files_to_modify" not in err for err in result.errors)
|
||||
|
||||
def test_warns_when_files_to_reference_missing(self, spec_dir: Path):
|
||||
"""Should warn when 'files_to_reference' is missing."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {"task_description": "Test task"}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("files_to_reference" in warn for warn in result.warnings)
|
||||
|
||||
def test_warns_when_scoped_services_missing(self, spec_dir: Path):
|
||||
"""Should warn when 'scoped_services' is missing."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {"task_description": "Test task"}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("scoped_services" in warn for warn in result.warnings)
|
||||
|
||||
def test_warns_for_empty_recommended_fields(self, spec_dir: Path):
|
||||
"""Should warn when recommended fields exist but are empty."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {
|
||||
"task_description": "Test task",
|
||||
"files_to_modify": [],
|
||||
"files_to_reference": None,
|
||||
}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Empty fields should trigger warnings
|
||||
assert any("files_to_modify" in warn for warn in result.warnings)
|
||||
|
||||
def test_no_warnings_when_recommended_fields_present(self, spec_dir: Path):
|
||||
"""Should not warn when all recommended fields are present."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {
|
||||
"task_description": "Test task",
|
||||
"files_to_modify": ["src/auth.py"],
|
||||
"files_to_reference": ["src/user.py"],
|
||||
"scoped_services": ["backend"],
|
||||
}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Check that no warnings for these fields exist
|
||||
assert not any("files_to_modify" in warn for warn in result.warnings)
|
||||
assert not any("files_to_reference" in warn for warn in result.warnings)
|
||||
assert not any("scoped_services" in warn for warn in result.warnings)
|
||||
|
||||
|
||||
class TestValidateValidContext:
|
||||
"""Tests for validate() with valid context.json."""
|
||||
|
||||
def test_returns_valid_for_minimal_context(self, spec_dir: Path):
|
||||
"""Should return valid result with minimal required fields."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {"task_description": "Implement OAuth login"}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert result.checkpoint == "context"
|
||||
assert len(result.errors) == 0
|
||||
# Warnings for missing recommended fields are expected
|
||||
|
||||
def test_returns_valid_with_all_fields(self, spec_dir: Path):
|
||||
"""Should return valid result with all fields present."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {
|
||||
"task_description": "Add OAuth",
|
||||
"scoped_services": ["backend", "frontend"],
|
||||
"files_to_modify": ["src/auth.py"],
|
||||
"files_to_reference": ["src/user.py"],
|
||||
"patterns": ["singleton pattern"],
|
||||
"service_contexts": {"backend": "FastAPI app"},
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert len(result.errors) == 0
|
||||
assert len(result.warnings) == 0
|
||||
|
||||
|
||||
class TestValidationResultStructure:
|
||||
"""Tests for ValidationResult structure and fields."""
|
||||
|
||||
def test_result_has_all_fields(self, spec_dir: Path):
|
||||
"""ValidationResult should have all expected fields."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"task_description": "Test"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Check all fields exist
|
||||
assert hasattr(result, "valid")
|
||||
assert hasattr(result, "checkpoint")
|
||||
assert hasattr(result, "errors")
|
||||
assert hasattr(result, "warnings")
|
||||
assert hasattr(result, "fixes")
|
||||
|
||||
def test_checkpoint_is_context(self, spec_dir: Path):
|
||||
"""Checkpoint field should always be 'context'."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"task_description": "Test"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.checkpoint == "context"
|
||||
|
||||
def test_fixes_only_on_invalid(self, spec_dir: Path):
|
||||
"""Fixes should only be present when validation fails."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
# Valid case - no fixes needed
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"task_description": "Test"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert len(result.fixes) == 0
|
||||
|
||||
def test_lists_are_initialized(self, spec_dir: Path):
|
||||
"""Errors, warnings, and fixes should always be lists."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text('{"task_description": "Test"}', encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert isinstance(result.errors, list)
|
||||
assert isinstance(result.warnings, list)
|
||||
assert isinstance(result.fixes, list)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_handles_unicode_in_context(self, spec_dir: Path):
|
||||
"""Should handle unicode characters in context.json."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_data = {
|
||||
"task_description": "添加用户认证",
|
||||
}
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_handles_large_context_file(self, spec_dir: Path):
|
||||
"""Should handle large context.json files."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
# Create a large context with many files
|
||||
context_data = {
|
||||
"task_description": "Large refactoring",
|
||||
"files_to_modify": [f"src/file{i}.py" for i in range(1000)],
|
||||
"files_to_reference": [f"lib/file{i}.py" for i in range(500)],
|
||||
}
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_handles_empty_context_object(self, spec_dir: Path):
|
||||
"""Should handle empty JSON object."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert any("task_description" in err for err in result.errors)
|
||||
|
||||
def test_handles_nested_json_structure(self, spec_dir: Path):
|
||||
"""Should handle nested JSON objects."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_data = {
|
||||
"task_description": "Complex task",
|
||||
"service_contexts": {
|
||||
"backend": {
|
||||
"framework": "FastAPI",
|
||||
"version": "0.100.0",
|
||||
"config": {"debug": True, "port": 8000},
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{"name": "singleton", "description": "Single instance"},
|
||||
{"name": "factory", "description": "Object creation"},
|
||||
],
|
||||
}
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_handles_extra_fields(self, spec_dir: Path):
|
||||
"""Should allow extra fields not in schema."""
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
context_data = {
|
||||
"task_description": "Test task",
|
||||
"custom_field": "custom value",
|
||||
"another_extra": 123,
|
||||
}
|
||||
|
||||
context_file = spec_dir / "context.json"
|
||||
context_file.write_text(json.dumps(context_data), encoding="utf-8")
|
||||
|
||||
validator = ContextValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Extra fields should not cause validation errors
|
||||
assert result.valid is True
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for spec/validate_pkg/validators/prereqs_validator.py
|
||||
===========================================================
|
||||
|
||||
Tests for PrereqsValidator class covering:
|
||||
- Spec directory existence checks
|
||||
- project_index.json existence checks
|
||||
- Auto-claude level fallback checks
|
||||
- ValidationResult return values
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def clean_project_index_files(spec_dir: Path) -> None:
|
||||
"""Remove project_index.json files that may interfere with tests.
|
||||
|
||||
Cleans up both:
|
||||
- spec_dir / "project_index.json"
|
||||
- spec_dir.parent.parent / "project_index.json" (auto-claude level)
|
||||
|
||||
This prevents test isolation issues when tests share the same temp_dir parent.
|
||||
"""
|
||||
# Clean spec_dir level
|
||||
spec_index = spec_dir / "project_index.json"
|
||||
if spec_index.exists():
|
||||
spec_index.unlink()
|
||||
|
||||
# Clean auto-claude level (two levels up from spec_dir)
|
||||
auto_build_index = spec_dir.parent.parent / "project_index.json"
|
||||
if auto_build_index.exists():
|
||||
auto_build_index.unlink()
|
||||
|
||||
|
||||
class TestPrereqsValidatorInit:
|
||||
"""Tests for PrereqsValidator initialization."""
|
||||
|
||||
def test_initialization_with_path(self, spec_dir: Path):
|
||||
"""PrereqsValidator initializes with spec_dir path."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
|
||||
assert validator.spec_dir == spec_dir
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
|
||||
def test_converts_string_to_path(self, spec_dir: Path):
|
||||
"""PrereqsValidator converts string path to Path object."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
validator = PrereqsValidator(str(spec_dir))
|
||||
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
assert validator.spec_dir == spec_dir
|
||||
|
||||
|
||||
class TestValidateSpecDirMissing:
|
||||
"""Tests for validate() when spec directory does not exist."""
|
||||
|
||||
def test_returns_error_when_spec_dir_missing(self, temp_dir: Path):
|
||||
"""Should return error when spec directory does not exist."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
non_existent_dir = temp_dir / "nonexistent" / "spec"
|
||||
validator = PrereqsValidator(non_existent_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert result.checkpoint == "prereqs"
|
||||
assert len(result.errors) > 0
|
||||
assert any("does not exist" in err.lower() for err in result.errors)
|
||||
|
||||
def test_error_includes_directory_path(self, temp_dir: Path):
|
||||
"""Error message should include the directory path."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
non_existent_dir = temp_dir / "missing" / "spec"
|
||||
validator = PrereqsValidator(non_existent_dir)
|
||||
result = validator.validate()
|
||||
|
||||
error_msg = result.errors[0]
|
||||
assert str(non_existent_dir) in error_msg
|
||||
|
||||
def test_fix_suggests_mkdir_command(self, temp_dir: Path):
|
||||
"""Suggested fix should include mkdir -p command."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
non_existent_dir = temp_dir / "new" / "spec"
|
||||
validator = PrereqsValidator(non_existent_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("mkdir" in fix.lower() for fix in result.fixes)
|
||||
assert any("-p" in fix for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateProjectIndexMissing:
|
||||
"""Tests for validate() when project_index.json is missing."""
|
||||
|
||||
def test_returns_error_when_project_index_missing(self, spec_dir: Path):
|
||||
"""Should return error when project_index.json does not exist."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
clean_project_index_files(spec_dir)
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert any("project_index.json" in err for err in result.errors)
|
||||
|
||||
def test_error_when_no_auto_claude_index(self, spec_dir: Path):
|
||||
"""Should error when project_index.json missing at both levels."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
clean_project_index_files(spec_dir)
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert not result.warnings # No warning if no auto-claude fallback exists
|
||||
|
||||
def test_fix_suggests_running_analyzer(self, spec_dir: Path):
|
||||
"""Suggested fix should suggest running analyzer.py."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
clean_project_index_files(spec_dir)
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("analyzer.py" in fix for fix in result.fixes)
|
||||
assert any("auto-claude" in fix for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateAutoClaudeFallback:
|
||||
"""Tests for validate() with auto-claude level project_index.json."""
|
||||
|
||||
def test_warns_when_auto_claude_index_exists(self, spec_dir: Path):
|
||||
"""Should warn when project_index.json exists at auto-claude/ level."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
# The validator checks spec_dir.parent.parent for the auto-claude index
|
||||
# Create project_index.json at the correct level (two levels up from spec_dir)
|
||||
auto_build_index = spec_dir.parent.parent / "project_index.json"
|
||||
auto_build_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
auto_build_index.write_text('{"project_type": "single"}', encoding="utf-8")
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# When auto-claude index exists but spec_dir index doesn't, it's valid with a warning
|
||||
assert result.valid is True # Valid because warning path, not error path
|
||||
assert len(result.warnings) > 0
|
||||
assert any("auto-claude" in warn or "spec folder" in warn for warn in result.warnings)
|
||||
|
||||
def test_fix_suggests_copy_command(self, spec_dir: Path):
|
||||
"""Suggested fix should include cp command when auto-claude index exists."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
# Create project_index.json at the auto-claude level (two levels up)
|
||||
auto_build_index = spec_dir.parent.parent / "project_index.json"
|
||||
auto_build_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
auto_build_index.write_text('{"project_type": "monorepo"}', encoding="utf-8")
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("cp" in fix for fix in result.fixes)
|
||||
assert any(str(auto_build_index) in fix for fix in result.fixes)
|
||||
|
||||
def test_no_warning_when_auto_claude_index_missing(self, spec_dir: Path):
|
||||
"""Should not warn when auto-claude level index also missing."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
clean_project_index_files(spec_dir)
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should be invalid since no index exists anywhere
|
||||
assert result.valid is False
|
||||
assert not any("auto-claude" in warn for warn in result.warnings)
|
||||
assert any("not found" in err for err in result.errors)
|
||||
|
||||
|
||||
class TestValidateValidPrereqs:
|
||||
"""Tests for validate() with valid prerequisites."""
|
||||
|
||||
def test_returns_valid_when_project_index_exists(self, spec_dir: Path):
|
||||
"""Should return valid when project_index.json exists in spec dir."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
project_index = spec_dir / "project_index.json"
|
||||
project_index.write_text('{"project_type": "single"}', encoding="utf-8")
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert result.checkpoint == "prereqs"
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_valid_with_valid_project_index_content(self, spec_dir: Path):
|
||||
"""Should be valid with properly structured project_index.json."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
project_index = spec_dir / "project_index.json"
|
||||
project_index.write_text(json.dumps({
|
||||
"project_type": "monorepo",
|
||||
"services": {
|
||||
"backend": {"path": "backend", "language": "python"},
|
||||
"frontend": {"path": "frontend", "language": "typescript"},
|
||||
},
|
||||
"file_count": 150,
|
||||
}), encoding="utf-8")
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
|
||||
class TestValidationResultStructure:
|
||||
"""Tests for ValidationResult structure."""
|
||||
|
||||
def test_result_has_all_fields(self, spec_dir: Path):
|
||||
"""ValidationResult should have all expected fields."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert hasattr(result, "valid")
|
||||
assert hasattr(result, "checkpoint")
|
||||
assert hasattr(result, "errors")
|
||||
assert hasattr(result, "warnings")
|
||||
assert hasattr(result, "fixes")
|
||||
|
||||
def test_checkpoint_is_prereqs(self, spec_dir: Path):
|
||||
"""Checkpoint field should always be 'prereqs'."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.checkpoint == "prereqs"
|
||||
|
||||
def test_lists_are_initialized(self, spec_dir: Path):
|
||||
"""Errors, warnings, and fixes should always be lists."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert isinstance(result.errors, list)
|
||||
assert isinstance(result.warnings, list)
|
||||
assert isinstance(result.fixes, list)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_handles_relative_paths(self, temp_dir: Path, monkeypatch):
|
||||
"""Should handle relative path arguments."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
# Create spec directory
|
||||
spec_path = temp_dir / "spec"
|
||||
spec_path.mkdir()
|
||||
|
||||
# Use relative path with monkeypatch for safe directory change
|
||||
relative_path = "spec"
|
||||
monkeypatch.chdir(temp_dir)
|
||||
validator = PrereqsValidator(relative_path)
|
||||
result = validator.validate()
|
||||
|
||||
# Should work (will be invalid since no project_index.json)
|
||||
assert result.checkpoint == "prereqs"
|
||||
|
||||
def test_handles_symlink_to_directory(self, temp_dir: Path):
|
||||
"""Should handle symlinks to directories."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
# Create actual spec directory
|
||||
actual_spec = temp_dir / "actual_spec"
|
||||
actual_spec.mkdir()
|
||||
|
||||
# Create symlink
|
||||
import os
|
||||
link_spec = temp_dir / "link_spec"
|
||||
try:
|
||||
os.symlink(actual_spec, link_spec)
|
||||
except OSError:
|
||||
# Symlinks may not be supported on all systems
|
||||
pytest.skip("Symlinks not supported")
|
||||
|
||||
validator = PrereqsValidator(link_spec)
|
||||
result = validator.validate()
|
||||
|
||||
# Should handle the symlinked directory
|
||||
assert result.checkpoint == "prereqs"
|
||||
|
||||
def test_multiple_validations_independent(self, spec_dir: Path):
|
||||
"""Multiple validations should be independent."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
clean_project_index_files(spec_dir)
|
||||
|
||||
validator1 = PrereqsValidator(spec_dir)
|
||||
result1 = validator1.validate()
|
||||
|
||||
# Create project_index.json between validations
|
||||
project_index = spec_dir / "project_index.json"
|
||||
project_index.write_text('{"project_type": "single"}', encoding="utf-8")
|
||||
|
||||
validator2 = PrereqsValidator(spec_dir)
|
||||
result2 = validator2.validate()
|
||||
|
||||
# First result should be invalid (no index existed at validation time)
|
||||
assert result1.valid is False
|
||||
# Second result should be valid (index now exists)
|
||||
assert result2.valid is True
|
||||
|
||||
def test_handles_empty_project_index(self, spec_dir: Path):
|
||||
"""Should handle empty project_index.json file."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
|
||||
project_index = spec_dir / "project_index.json"
|
||||
project_index.write_text("{}", encoding="utf-8")
|
||||
|
||||
validator = PrereqsValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should be valid since file exists (content validation not required)
|
||||
assert result.valid is True
|
||||
|
||||
|
||||
class TestPrereqsValidatorIntegration:
|
||||
"""Integration tests with other validators."""
|
||||
|
||||
def test_works_with_context_validator(self, spec_dir: Path):
|
||||
"""Should work correctly when used with ContextValidator."""
|
||||
from spec.validate_pkg.validators.prereqs_validator import PrereqsValidator
|
||||
from spec.validate_pkg.validators.context_validator import ContextValidator
|
||||
|
||||
# Create project_index.json
|
||||
project_index = spec_dir / "project_index.json"
|
||||
project_index.write_text('{"project_type": "single"}', encoding="utf-8")
|
||||
|
||||
prereq_validator = PrereqsValidator(spec_dir)
|
||||
prereq_result = prereq_validator.validate()
|
||||
|
||||
context_validator = ContextValidator(spec_dir)
|
||||
context_result = context_validator.validate()
|
||||
|
||||
# Prereqs should be valid
|
||||
assert prereq_result.valid is True
|
||||
# Context should be invalid (no context.json)
|
||||
assert context_result.valid is False
|
||||
@@ -1,486 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for spec/validate_pkg/validators/spec_document_validator.py
|
||||
=================================================================
|
||||
|
||||
Tests for SpecDocumentValidator class covering:
|
||||
- File existence checks
|
||||
- Required section validation
|
||||
- Recommended section warnings
|
||||
- Content length validation
|
||||
- ValidationResult return values
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestSpecDocumentValidatorInit:
|
||||
"""Tests for SpecDocumentValidator initialization."""
|
||||
|
||||
def test_initialization_with_path(self, spec_dir: Path):
|
||||
"""SpecDocumentValidator initializes with spec_dir path."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
|
||||
assert validator.spec_dir == spec_dir
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
|
||||
def test_converts_string_to_path(self, spec_dir: Path):
|
||||
"""SpecDocumentValidator converts string path to Path object."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
validator = SpecDocumentValidator(str(spec_dir))
|
||||
|
||||
assert isinstance(validator.spec_dir, Path)
|
||||
assert validator.spec_dir == spec_dir
|
||||
|
||||
|
||||
class TestValidateFileNotFound:
|
||||
"""Tests for validate() when spec.md does not exist."""
|
||||
|
||||
def test_returns_error_when_file_missing(self, spec_dir: Path):
|
||||
"""Should return ValidationResult with error when spec.md missing."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert result.checkpoint == "spec"
|
||||
assert any("not found" in err.lower() or "spec.md" in err.lower() for err in result.errors)
|
||||
|
||||
def test_error_message_includes_filename(self, spec_dir: Path):
|
||||
"""Error message should mention spec.md."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert "spec.md" in result.errors[0]
|
||||
|
||||
def test_fix_suggests_creation(self, spec_dir: Path):
|
||||
"""Suggested fix should mention creating spec.md."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("create" in fix.lower() for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateRequiredSections:
|
||||
"""Tests for validate() with missing required sections."""
|
||||
|
||||
def test_error_when_overview_missing(self, spec_dir: Path):
|
||||
"""Should error when required section 'Overview' is missing."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("# Other Section\n\nContent here.\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert any("overview" in err.lower() for err in result.errors)
|
||||
|
||||
def test_error_for_all_required_sections_missing(self, spec_dir: Path):
|
||||
"""Should list all missing required sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
from spec.validate_pkg.schemas import SPEC_REQUIRED_SECTIONS
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("# Other\n\nContent.\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Check that all required sections are mentioned in errors
|
||||
for section in SPEC_REQUIRED_SECTIONS:
|
||||
assert any(section.lower() in err.lower() for err in result.errors), \
|
||||
f"Section {section} not in errors"
|
||||
|
||||
def test_accepts_hash_hash_format(self, spec_dir: Path):
|
||||
"""Should accept ## Section format (double hash)."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nContent\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_accepts_single_hash_format(self, spec_dir: Path):
|
||||
"""Should accept # Section format (single hash)."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "# Overview\n\nContent\n\n# Workflow Type\n\nFeature\n\n"
|
||||
content += "# Task Scope\n\nScope\n\n# Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_case_insensitive_section_matching(self, spec_dir: Path):
|
||||
"""Should match sections case-insensitively."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## OVERVIEW\n\nContent\n\n## workflow type\n\nFeature\n\n"
|
||||
content += "## task scope\n\nScope\n\n## success criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_fixes_suggest_adding_sections(self, spec_dir: Path):
|
||||
"""Suggested fixes should include adding missing sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("# Other\n\nContent.\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Fixes should suggest adding sections
|
||||
assert any("##" in fix for fix in result.fixes)
|
||||
|
||||
|
||||
class TestValidateRecommendedSections:
|
||||
"""Tests for validate() with recommended sections."""
|
||||
|
||||
def test_warns_when_files_to_modify_missing(self, spec_dir: Path):
|
||||
"""Should warn when 'Files to Modify' section is missing."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nContent\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Missing recommended section should be a warning, not error
|
||||
assert any("files to modify" in warn.lower() for warn in result.warnings)
|
||||
|
||||
def test_warns_for_multiple_missing_recommended(self, spec_dir: Path):
|
||||
"""Should warn for all missing recommended sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nContent\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should have warnings for missing recommended sections
|
||||
assert len(result.warnings) > 0
|
||||
|
||||
def test_no_warnings_with_all_recommended(self, spec_dir: Path):
|
||||
"""Should not warn when all recommended sections present."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
from spec.validate_pkg.schemas import SPEC_RECOMMENDED_SECTIONS
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nThis is a comprehensive overview of the feature that we are building.\n\n"
|
||||
content += "## Workflow Type\n\nFeature implementation workflow with multiple phases.\n\n"
|
||||
content += "## Task Scope\n\nThe scope includes backend API changes and database updates.\n\n"
|
||||
content += "## Success Criteria\n\nAll tests pass and the feature works as expected.\n\n"
|
||||
|
||||
# Add all recommended sections with substantial content
|
||||
for section in SPEC_RECOMMENDED_SECTIONS:
|
||||
content += f"## {section}\n\nThis section contains detailed information about {section.lower()}. "
|
||||
content += "We need to ensure that all requirements are properly documented and reviewed.\n\n"
|
||||
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert len(result.warnings) == 0
|
||||
|
||||
|
||||
class TestValidateContentLength:
|
||||
"""Tests for content length validation."""
|
||||
|
||||
def test_warns_when_content_too_short(self, spec_dir: Path):
|
||||
"""Should warn when spec.md is less than 500 characters."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nShort.\n\n## Workflow Type\n\nX\n\n"
|
||||
content += "## Task Scope\n\nY\n\n## Success Criteria\n\nZ\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert any("too short" in warn.lower() for warn in result.warnings)
|
||||
|
||||
def test_no_warning_for_adequate_length(self, spec_dir: Path):
|
||||
"""Should not warn when spec.md has adequate length."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
# Create content longer than 500 characters
|
||||
content = "## Overview\n\n" + "X" * 600 + "\n\n"
|
||||
content += "## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n"
|
||||
content += "## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert not any("too short" in warn.lower() for warn in result.warnings)
|
||||
|
||||
def test_content_check_counts_all_characters(self, spec_dir: Path):
|
||||
"""Content length check should count all characters including whitespace."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
# Create content exactly over 500 characters with mixed content
|
||||
content = "## Overview\n\n" + "A" * 480 + "\n\n"
|
||||
content += "## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n"
|
||||
content += "## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should not have length warning
|
||||
assert not any("too short" in warn.lower() for warn in result.warnings)
|
||||
|
||||
|
||||
class TestValidateValidSpec:
|
||||
"""Tests for validate() with valid spec.md."""
|
||||
|
||||
def test_returns_valid_for_minimal_spec(self, spec_dir: Path):
|
||||
"""Should return valid with minimal required sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nImplement feature.\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nAdd user auth.\n\n## Success Criteria\n\nTests pass.\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert result.checkpoint == "spec"
|
||||
# May have warnings about recommended sections or length
|
||||
|
||||
def test_returns_valid_with_comprehensive_spec(self, spec_dir: Path):
|
||||
"""Should return valid with comprehensive spec document."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
from spec.validate_pkg.schemas import SPEC_REQUIRED_SECTIONS, SPEC_RECOMMENDED_SECTIONS
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = ""
|
||||
|
||||
# Add all required sections
|
||||
for section in SPEC_REQUIRED_SECTIONS:
|
||||
content += f"## {section}\n\nDetailed content for {section}.\n\n"
|
||||
|
||||
# Add all recommended sections
|
||||
for section in SPEC_RECOMMENDED_SECTIONS:
|
||||
content += f"## {section}\n\nDetails about {section}.\n\n"
|
||||
|
||||
# Add more content to avoid length warning
|
||||
content += "Additional implementation details..." * 50
|
||||
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
assert len(result.errors) == 0
|
||||
assert len(result.warnings) == 0
|
||||
|
||||
|
||||
class TestValidationResultStructure:
|
||||
"""Tests for ValidationResult structure."""
|
||||
|
||||
def test_result_has_all_fields(self, spec_dir: Path):
|
||||
"""ValidationResult should have all expected fields."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("## Overview\n\nContent\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert hasattr(result, "valid")
|
||||
assert hasattr(result, "checkpoint")
|
||||
assert hasattr(result, "errors")
|
||||
assert hasattr(result, "warnings")
|
||||
assert hasattr(result, "fixes")
|
||||
|
||||
def test_checkpoint_is_spec(self, spec_dir: Path):
|
||||
"""Checkpoint field should always be 'spec'."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("## Overview\n\nContent\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.checkpoint == "spec"
|
||||
|
||||
def test_lists_are_initialized(self, spec_dir: Path):
|
||||
"""Errors, warnings, and fixes should always be lists."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("## Overview\n\nContent\n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert isinstance(result.errors, list)
|
||||
assert isinstance(result.warnings, list)
|
||||
assert isinstance(result.fixes, list)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_handles_unicode_in_spec(self, spec_dir: Path):
|
||||
"""Should handle unicode characters in spec.md."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\n添加用户认证功能\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\n范围\n\n## Success Criteria\n\n完成\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_handles_extra_whitespace(self, spec_dir: Path):
|
||||
"""Should handle extra whitespace in sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview \n\nContent\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should still match despite extra whitespace
|
||||
assert result.valid is True
|
||||
|
||||
def test_handles_mixed_heading_levels(self, spec_dir: Path):
|
||||
"""Should handle spec with various heading levels."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview\n\nContent\n\n### Subsection\n\nDetails\n\n"
|
||||
content += "## Workflow Type\n\nFeature\n\n## Task Scope\n\nScope\n\n"
|
||||
content += "## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is True
|
||||
|
||||
def test_section_pattern_excludes_subsections(self, spec_dir: Path):
|
||||
"""Should not match subsections (###) as main sections."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
# Only has subsections, not main sections
|
||||
content = "### Overview\n\nContent\n\n### Workflow Type\n\nFeature\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should be invalid - ### doesn't count as ## or #
|
||||
assert result.valid is False
|
||||
|
||||
def test_handles_empty_spec_file(self, spec_dir: Path):
|
||||
"""Should handle empty spec.md file."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
# Should warn about being too short
|
||||
assert any("too short" in warn.lower() for warn in result.warnings)
|
||||
|
||||
def test_handles_spec_with_only_whitespace(self, spec_dir: Path):
|
||||
"""Should handle spec.md with only whitespace."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text(" \n\n \n", encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
assert result.valid is False
|
||||
assert any("too short" in warn.lower() for warn in result.warnings)
|
||||
|
||||
|
||||
class TestSectionMatching:
|
||||
"""Tests for section heading pattern matching."""
|
||||
|
||||
def test_matches_section_with_trailing_colon(self, spec_dir: Path):
|
||||
"""Should match sections with trailing colon."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview:\n\nContent\n\n## Workflow Type:\n\nFeature\n\n"
|
||||
content += "## Task Scope:\n\nScope\n\n## Success Criteria:\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should match despite trailing colon
|
||||
assert result.valid is True
|
||||
|
||||
def test_matches_section_with_special_chars(self, spec_dir: Path):
|
||||
"""Should match sections with special characters."""
|
||||
from spec.validate_pkg.validators.spec_document_validator import SpecDocumentValidator
|
||||
|
||||
spec_file = spec_dir / "spec.md"
|
||||
content = "## Overview (v2.0)\n\nContent\n\n## Workflow Type\n\nFeature\n\n"
|
||||
content += "## Task Scope\n\nScope\n\n## Success Criteria\n\nDone\n"
|
||||
spec_file.write_text(content, encoding="utf-8")
|
||||
|
||||
validator = SpecDocumentValidator(spec_dir)
|
||||
result = validator.validate()
|
||||
|
||||
# Should still match
|
||||
assert result.valid is True
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Tests for Structured Output Recovery
|
||||
======================================
|
||||
|
||||
Tests the three-tier recovery cascade when structured output validation fails:
|
||||
1. FollowupExtractionResponse model validation
|
||||
2. Error categorization imported from sdk_utils
|
||||
3. Agent config registration for pr_followup_extraction
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add paths for imports — conftest.py adds apps/backend, but there's a
|
||||
# services/ package at both apps/backend/services/ and runners/github/services/.
|
||||
# To avoid collision, add the github services dir directly and import bare module names.
|
||||
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
_github_services_dir = _backend_dir / "runners" / "github" / "services"
|
||||
if str(_backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_backend_dir))
|
||||
if str(_github_services_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_services_dir))
|
||||
|
||||
from agents.tools_pkg.models import AGENT_CONFIGS
|
||||
from pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from sdk_utils import RECOVERABLE_ERRORS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test FollowupExtractionResponse model
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestFollowupExtractionResponse:
|
||||
"""Tests for the minimal extraction schema."""
|
||||
|
||||
def test_minimal_valid_response(self):
|
||||
"""Accepts minimal response with just verdict and reasoning."""
|
||||
resp = FollowupExtractionResponse(
|
||||
verdict="NEEDS_REVISION",
|
||||
verdict_reasoning="Found issues that need fixing",
|
||||
)
|
||||
assert resp.verdict == "NEEDS_REVISION"
|
||||
assert resp.resolved_finding_ids == []
|
||||
assert resp.new_finding_summaries == []
|
||||
assert resp.confirmed_finding_count == 0
|
||||
assert resp.dismissed_finding_count == 0
|
||||
|
||||
def test_full_valid_response(self):
|
||||
"""Accepts fully populated response."""
|
||||
resp = FollowupExtractionResponse(
|
||||
verdict="READY_TO_MERGE",
|
||||
verdict_reasoning="All findings resolved",
|
||||
resolved_finding_ids=["NCR-001", "NCR-002"],
|
||||
unresolved_finding_ids=[],
|
||||
new_finding_summaries=["HIGH: potential cleanup issue in batch_commands.py"],
|
||||
confirmed_finding_count=1,
|
||||
dismissed_finding_count=1,
|
||||
)
|
||||
assert len(resp.resolved_finding_ids) == 2
|
||||
assert len(resp.new_finding_summaries) == 1
|
||||
assert resp.confirmed_finding_count == 1
|
||||
|
||||
def test_schema_is_small(self):
|
||||
"""Schema should be significantly smaller than ParallelFollowupResponse."""
|
||||
extraction_schema = json.dumps(
|
||||
FollowupExtractionResponse.model_json_schema()
|
||||
)
|
||||
followup_schema = json.dumps(
|
||||
ParallelFollowupResponse.model_json_schema()
|
||||
)
|
||||
# Extraction schema should be less than half the size of the full schema
|
||||
assert len(extraction_schema) < len(followup_schema) / 2, (
|
||||
f"Extraction schema ({len(extraction_schema)} chars) should be "
|
||||
f"less than half of full schema ({len(followup_schema)} chars)"
|
||||
)
|
||||
|
||||
def test_all_verdict_values_accepted(self):
|
||||
"""All four verdict values should be accepted."""
|
||||
for verdict in ["READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"]:
|
||||
resp = FollowupExtractionResponse(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=f"Test {verdict}",
|
||||
)
|
||||
assert resp.verdict == verdict
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test error categorization using the actual RECOVERABLE_ERRORS from sdk_utils
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestErrorCategorization:
|
||||
"""Tests that sdk_utils RECOVERABLE_ERRORS constant classifies errors correctly."""
|
||||
|
||||
def test_structured_output_error_is_recoverable(self):
|
||||
"""structured_output_validation_failed should be in RECOVERABLE_ERRORS."""
|
||||
assert "structured_output_validation_failed" in RECOVERABLE_ERRORS
|
||||
|
||||
def test_concurrency_error_is_recoverable(self):
|
||||
"""tool_use_concurrency_error should be in RECOVERABLE_ERRORS."""
|
||||
assert "tool_use_concurrency_error" in RECOVERABLE_ERRORS
|
||||
|
||||
def test_auth_error_is_fatal(self):
|
||||
"""Auth errors should NOT be in RECOVERABLE_ERRORS."""
|
||||
assert "Authentication error detected in AI response: please login again" not in RECOVERABLE_ERRORS
|
||||
|
||||
def test_circuit_breaker_is_fatal(self):
|
||||
"""Circuit breaker errors should NOT be in RECOVERABLE_ERRORS."""
|
||||
for error in RECOVERABLE_ERRORS:
|
||||
assert "circuit breaker" not in error.lower()
|
||||
|
||||
def test_none_is_not_recoverable(self):
|
||||
"""None should not be in RECOVERABLE_ERRORS."""
|
||||
assert None not in RECOVERABLE_ERRORS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test agent config registration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestAgentConfigRegistration:
|
||||
"""Tests that pr_followup_extraction agent type is registered."""
|
||||
|
||||
def test_extraction_agent_type_registered(self):
|
||||
"""pr_followup_extraction must exist in AGENT_CONFIGS."""
|
||||
assert "pr_followup_extraction" in AGENT_CONFIGS
|
||||
|
||||
def test_extraction_agent_needs_no_tools(self):
|
||||
"""Extraction agent should have no tools (pure structured output)."""
|
||||
config = AGENT_CONFIGS["pr_followup_extraction"]
|
||||
assert config["tools"] == []
|
||||
assert config["mcp_servers"] == []
|
||||
|
||||
def test_extraction_agent_low_thinking(self):
|
||||
"""Extraction agent should use low thinking (lightweight call)."""
|
||||
config = AGENT_CONFIGS["pr_followup_extraction"]
|
||||
assert config["thinking_default"] == "low"
|
||||
@@ -631,17 +631,11 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, builder):
|
||||
"""Test handling of non-existent directory."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
|
||||
# Mock multiple Path methods to avoid permission errors on nonexistent paths
|
||||
with patch.object(Path, 'exists', return_value=False), \
|
||||
patch.object(Path, 'is_dir', return_value=False), \
|
||||
patch.object(Path, 'glob', return_value=[]):
|
||||
# Should not crash, returns unknown
|
||||
strategy = builder.build_strategy(fake_dir, fake_dir, "medium")
|
||||
assert strategy.project_type == "unknown"
|
||||
# Should not crash, returns unknown
|
||||
strategy = builder.build_strategy(fake_dir, fake_dir, "medium")
|
||||
assert strategy.project_type == "unknown"
|
||||
|
||||
def test_empty_risk_level_defaults_medium(self, builder, temp_dir):
|
||||
"""Test that None risk level defaults to medium."""
|
||||
|
||||
Reference in New Issue
Block a user