Compare commits

..

13 Commits

Author SHA1 Message Date
Andy 30855be0c8 Merge branch 'develop' into fix/roadmap-tasks 2026-02-12 12:45:45 +01:00
AndyMik90 a96ce9164f fix(roadmap): clear task_outcome in IPC handler and add test coverage
- ROADMAP_UPDATE_FEATURE handler now clears task_outcome and
  previous_status when status moves away from done, matching the
  renderer store behavior
- Add tests for markFeatureDoneBySpecId (previousStatus preservation,
  taskOutcome setting, feature isolation)
- Add tests for updateFeatureStatus clearing taskOutcome/previousStatus
2026-02-12 12:05:55 +01:00
AndyMik90 b14c4f4bec fix(roadmap): clear taskOutcome when feature is moved away from done
When dragging a feature out of the 'done' column via Kanban, clear
taskOutcome and previousStatus so stale outcome badges don't persist.
2026-02-12 11:07:59 +01:00
Andy d0478f4ec1 Merge branch 'develop' into fix/roadmap-tasks 2026-02-12 10:47:01 +01:00
AndyMik90 487f90b5b8 fix(roadmap): preserve previousStatus in renderer and guard empty task list
- Add previousStatus preservation to markFeatureDoneBySpecId so renderer
  path matches backend behavior for unarchive revert
- Guard reconcileLinkedFeatures against empty task arrays to prevent
  falsely marking all linked features as deleted
- Fix broken code fence in CLAUDE.md (2 backticks → 3)
2026-02-12 10:41:23 +01:00
AndyMik90 6d3a524bda fix(roadmap): round-trip previous_status and add backend completed handling
- Add previousStatus to RoadmapFeature interface so it survives
  renderer-initiated saves through the ROADMAP_SAVE handler
- Map previous_status in both ROADMAP_GET and ROADMAP_SAVE handlers
- Add backend-side roadmap update on PR creation so completed outcome
  is handled server-side like deleted and archived outcomes
2026-02-12 10:23:22 +01:00
AndyMik90 bf05d58e33 update to .md 2026-02-12 10:20:57 +01:00
AndyMik90 6602b83054 fix(roadmap): preserve original status on outcome update and fix deletion ordering
- Save previous_status before overwriting to 'done' so unarchive restores
  the correct original status instead of always defaulting to 'in_progress'
- Move roadmap feature update after hasErrors check in task deletion so
  roadmap is only updated on successful deletion
2026-02-12 09:55:10 +01:00
AndyMik90 079dcb6341 fix(roadmap): revert feature state when task is unarchived
When unarchiveTasks() is called, linked roadmap features are now reverted
from status='done'/taskOutcome='archived' back to status='in_progress'
with taskOutcome cleared. Without this, unarchived tasks left their
roadmap features permanently stuck in the archived state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:29:59 +01:00
AndyMik90 d769d0a5a7 refactor(roadmap): extract TaskOutcome type and shared badge component
- Extract TaskOutcome type alias in shared/types/roadmap.ts, replacing
  inline union types across 5 locations (follows codebase convention)
- Create TaskOutcomeBadge shared component with consistent icon/color
  per outcome: completed=CheckCircle2/green, archived=Archive/green,
  deleted=Trash2/muted — eliminates duplicated rendering logic across
  SortableFeatureCard, FeatureCard, FeatureDetailPanel, PhaseCard
- Use text-muted-foreground for deleted outcome instead of misleading
  green success styling in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:14:02 +01:00
AndyMik90 ccdd721d9c fix(roadmap): address follow-up PR review findings
- Fix relative path bug: use path.join(project.path, AUTO_BUILD_PATHS)
  instead of path.join(autoBuildPath, 'roadmap') which produced relative
  paths causing roadmap updates to silently fail
- Allow taskOutcome transitions on already-done features (e.g.,
  completed→deleted) by relaxing the status check condition
- Extract withFileLock into shared file-lock.ts module so roadmap-utils
  and roadmap-handlers use the same lock map for cross-module coordination
- Show Trash2 icon for deleted tasks in PhaseCard instead of misleading
  green checkmark (visual distinction from completed)
- Remove unused writeFileAtomicSync import from crud-handlers.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:43:34 +01:00
AndyMik90 e0e114dc9f fix(roadmap): address PR review findings
- Extract shared updateRoadmapFeatureOutcome utility with file locking
  and retry logic (eliminates duplication between crud-handlers and
  project-store, matches established roadmap-handlers pattern)
- Fix stale Zustand state read in useIpc.ts — re-read state after
  markFeatureDoneBySpecId mutation to persist correct data
- Add .catch() to saveRoadmap call in useIpc.ts for error handling
- Add Archive icon for archived outcome in PhaseCard (consistency with
  FeatureCard, SortableFeatureCard, and FeatureDetailPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:15:34 +01:00
AndyMik90 1a893c05bb feat(roadmap): sync roadmap features with task lifecycle
When a roadmap feature is linked to a task (via linkedSpecId), the feature
now automatically updates when the task is completed, deleted, or archived.
Previously, features would show a broken "Go to Task" button pointing to
non-existent tasks.

- Add taskOutcome field to RoadmapFeature type
- Hook into task status changes (IPC listener) for real-time sync
- Update linked features on task deletion (main process)
- Update linked features on task archival (main process)
- Add startup reconciliation to catch missed updates
- Show status badges instead of broken "Go to Task" buttons
- Use AUTO_BUILD_PATHS constants and writeFileAtomicSync for consistency
- Add i18n translations (en/fr) for task outcome labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 22:10:03 +01:00
38 changed files with 144 additions and 5376 deletions
+7 -7
View File
@@ -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 -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.4-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.3-orange?style=flat-square)](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.
+1 -1
View File
@@ -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"
-8
View File
@@ -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 -17
View File
@@ -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 -1
View File
@@ -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>
@@ -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" />
@@ -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 {
@@ -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
View File
@@ -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
View File
@@ -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
# =============================================================================
-376
View File
@@ -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"
+3 -5
View File
@@ -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."""
+7 -12
View File
@@ -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."""
-497
View File
@@ -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
-506
View File
@@ -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
+3 -4
View File
@@ -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."""
+3 -5
View File
@@ -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
-145
View File
@@ -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"
+3 -9
View File
@@ -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."""