devforgeai 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +120 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/devforgeai-qa/SKILL.md +1 -1
- package/src/claude/skills/researching-market/SKILL.md +2 -1
- package/src/cli/lib/copier.js +13 -1
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
|
@@ -0,0 +1,1902 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive Test Suite for devforgeai invoke-hooks CLI Command
|
|
3
|
+
Tests generated following TDD Red Phase (failing tests first)
|
|
4
|
+
|
|
5
|
+
Story: STORY-022 - Implement devforgeai invoke-hooks CLI command
|
|
6
|
+
Test Framework: pytest with AAA pattern (Arrange, Act, Assert)
|
|
7
|
+
Coverage Target: >90% line, >85% branch
|
|
8
|
+
|
|
9
|
+
Acceptance Criteria Coverage:
|
|
10
|
+
AC1: Basic Command Structure - Accepts --operation and --story, returns exit code 0/1
|
|
11
|
+
AC2: Context Extraction - Extracts todos, status, errors, timing; sanitizes secrets; limits to 50KB
|
|
12
|
+
AC3: Feedback Skill Invocation - Invokes skill with pre-populated context, starts conversation
|
|
13
|
+
AC4: Graceful Degradation - Errors logged, exit code 1, parent continues
|
|
14
|
+
AC5: Timeout Protection - 30-second timeout, aborts gracefully, returns exit code 1
|
|
15
|
+
AC6: Circular Invocation Guard - Detects via DEVFORGEAI_HOOK_ACTIVE env var, blocks re-entry
|
|
16
|
+
AC7: Operation History Tracking - Session includes operation_id, story_id, timestamp linking
|
|
17
|
+
AC8: Performance Under Load - Multiple concurrent invocations succeed, no crashes, >99% success rate
|
|
18
|
+
|
|
19
|
+
Technical Specification Coverage:
|
|
20
|
+
COMP-001: invoke_hooks() function with operation, story_id arguments
|
|
21
|
+
COMP-002: Context extraction from TodoWrite, errors, timing data
|
|
22
|
+
COMP-003: Secret sanitization (50+ patterns)
|
|
23
|
+
COMP-004: devforgeai-feedback skill invocation with context
|
|
24
|
+
COMP-005: Graceful error handling (no exceptions to caller)
|
|
25
|
+
COMP-006: 30-second timeout with abort mechanism
|
|
26
|
+
COMP-007: Circular invocation detection via DEVFORGEAI_HOOK_ACTIVE
|
|
27
|
+
WORK-001: Extract todos from TodoWrite (status, content)
|
|
28
|
+
WORK-002: Extract errors (message, stack trace)
|
|
29
|
+
WORK-003: Calculate operation timing
|
|
30
|
+
WORK-004: Limit context size to 50KB
|
|
31
|
+
API-001: CLI command 'devforgeai invoke-hooks' with Click framework
|
|
32
|
+
API-002: Accept --operation argument (required)
|
|
33
|
+
API-003: Accept --story argument (optional, format STORY-NNN)
|
|
34
|
+
API-004: Return exit code 0 on success, 1 on failure
|
|
35
|
+
LOG-001 through LOG-005: Logging requirements
|
|
36
|
+
|
|
37
|
+
Edge Cases Covered:
|
|
38
|
+
- Missing TodoWrite data
|
|
39
|
+
- Skill invocation throws exception
|
|
40
|
+
- Feedback conversation user exits early
|
|
41
|
+
- Multiple concurrent invocations
|
|
42
|
+
- Context extraction fails
|
|
43
|
+
- Story ID invalid format
|
|
44
|
+
- Context size exceeding 50KB
|
|
45
|
+
- Secrets in various patterns (API keys, passwords, tokens, AWS keys, DB creds)
|
|
46
|
+
- Timeout during skill execution
|
|
47
|
+
- Circular invocation detection
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import logging
|
|
51
|
+
import os
|
|
52
|
+
import sys
|
|
53
|
+
import json
|
|
54
|
+
import time
|
|
55
|
+
import tempfile
|
|
56
|
+
import threading
|
|
57
|
+
import pytest
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from datetime import datetime, timedelta
|
|
60
|
+
from unittest.mock import Mock, MagicMock, patch, mock_open, call
|
|
61
|
+
from io import StringIO
|
|
62
|
+
import signal
|
|
63
|
+
|
|
64
|
+
# Import the invoke_hooks command (will fail until implementation exists)
|
|
65
|
+
try:
|
|
66
|
+
from devforgeai_cli.hooks import (
|
|
67
|
+
invoke_hooks,
|
|
68
|
+
HookInvocationService,
|
|
69
|
+
)
|
|
70
|
+
from devforgeai_cli.context_extraction import (
|
|
71
|
+
ContextExtractor,
|
|
72
|
+
extract_context,
|
|
73
|
+
sanitize_context,
|
|
74
|
+
)
|
|
75
|
+
from devforgeai_cli.commands.invoke_hooks import (
|
|
76
|
+
invoke_hooks_command,
|
|
77
|
+
EXIT_CODE_SUCCESS,
|
|
78
|
+
EXIT_CODE_FAILURE,
|
|
79
|
+
)
|
|
80
|
+
except ImportError:
|
|
81
|
+
# Placeholders for development
|
|
82
|
+
invoke_hooks = None
|
|
83
|
+
HookInvocationService = None
|
|
84
|
+
ContextExtractor = None
|
|
85
|
+
extract_context = None
|
|
86
|
+
sanitize_context = None
|
|
87
|
+
invoke_hooks_command = None
|
|
88
|
+
EXIT_CODE_SUCCESS = 0
|
|
89
|
+
EXIT_CODE_FAILURE = 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ============================================================================
|
|
93
|
+
# CONSTANTS - Test Data and Expectations
|
|
94
|
+
# ============================================================================
|
|
95
|
+
|
|
96
|
+
SECRET_PATTERNS_TO_TEST = [
|
|
97
|
+
# API Keys
|
|
98
|
+
("api_key: sk-1234567890abcdef", "api_key: ***"),
|
|
99
|
+
("API_KEY=sk-proj-abcd1234efgh5678", "API_KEY=***"),
|
|
100
|
+
("apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "apikey: ***"),
|
|
101
|
+
|
|
102
|
+
# Passwords
|
|
103
|
+
("password: SuperSecret123!", "password: ***"),
|
|
104
|
+
("passwd: qwerty123", "passwd: ***"),
|
|
105
|
+
("pwd=mypassword", "pwd=***"),
|
|
106
|
+
("user_password: abc123xyz", "user_password: ***"),
|
|
107
|
+
|
|
108
|
+
# OAuth Tokens
|
|
109
|
+
("access_token: ghp_abcd1234efgh5678ijkl9012mnop", "access_token: ***"),
|
|
110
|
+
("refresh_token: ghr_abcd1234efgh5678ijkl9012mnop", "refresh_token: ***"),
|
|
111
|
+
("token: bearer_1234567890abcdef", "token: ***"),
|
|
112
|
+
|
|
113
|
+
# AWS Keys
|
|
114
|
+
("aws_access_key_id: AKIAIOSFODNN7EXAMPLE", "aws_access_key_id: ***"),
|
|
115
|
+
("aws_secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws_secret_access_key: ***"),
|
|
116
|
+
("AWS_SECRET_ACCESS_KEY=abcd1234efgh5678ijkl", "AWS_SECRET_ACCESS_KEY=***"),
|
|
117
|
+
|
|
118
|
+
# Database Credentials
|
|
119
|
+
("database_url: postgresql://user:pass@localhost/db", "database_url: postgresql://user:***@localhost/db"),
|
|
120
|
+
("DATABASE_PASSWORD=securepass123", "DATABASE_PASSWORD=***"),
|
|
121
|
+
("mongodb_uri: mongodb+srv://user:password@cluster.mongodb.net", "mongodb_uri: mongodb+srv://user:***@cluster.mongodb.net"),
|
|
122
|
+
|
|
123
|
+
# GCP Keys
|
|
124
|
+
("GCP_SERVICE_ACCOUNT_KEY: {\"type\": \"service_account\"}", "GCP_SERVICE_ACCOUNT_KEY: ***"),
|
|
125
|
+
("GOOGLE_CLOUD_API_KEY: AIzaSyAbcd1234efgh5678ijkl", "GOOGLE_CLOUD_API_KEY: ***"),
|
|
126
|
+
|
|
127
|
+
# GitHub Tokens
|
|
128
|
+
("github_token: ghp_16C7e42F292c6912E7710c838347Ae178B4a", "github_token: ***"),
|
|
129
|
+
("GITHUB_PAT: ghp_abcd1234efgh5678ijkl9012mnop", "GITHUB_PAT: ***"),
|
|
130
|
+
|
|
131
|
+
# SSH Keys
|
|
132
|
+
("ssh_key: -----BEGIN RSA PRIVATE KEY-----", "ssh_key: -----BEGIN RSA PRIVATE KEY-----"), # Private key not in plaintext
|
|
133
|
+
|
|
134
|
+
# JWT Tokens
|
|
135
|
+
("jwt: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "jwt: ***"),
|
|
136
|
+
|
|
137
|
+
# PII Patterns
|
|
138
|
+
("ssn: 123-45-6789", "ssn: ***"),
|
|
139
|
+
("credit_card: 4532015112830366", "credit_card: ***"),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
MOCK_TODO_WRITE_DATA = {
|
|
143
|
+
"todos": [
|
|
144
|
+
{
|
|
145
|
+
"id": "todo-1",
|
|
146
|
+
"content": "Run TDD Red phase",
|
|
147
|
+
"status": "completed",
|
|
148
|
+
"activeForm": False,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"id": "todo-2",
|
|
152
|
+
"content": "Implement test-automator subagent",
|
|
153
|
+
"status": "in_progress",
|
|
154
|
+
"activeForm": True,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"id": "todo-3",
|
|
158
|
+
"content": "Run TDD Green phase",
|
|
159
|
+
"status": "pending",
|
|
160
|
+
"activeForm": False,
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
MOCK_CONTEXT_DATA = {
|
|
166
|
+
"operation_id": "dev-STORY-001-20251112-143022",
|
|
167
|
+
"operation": "dev",
|
|
168
|
+
"story_id": "STORY-001",
|
|
169
|
+
"start_time": "2025-11-12T14:30:22Z",
|
|
170
|
+
"end_time": "2025-11-12T14:35:18Z",
|
|
171
|
+
"duration": 296,
|
|
172
|
+
"status": "completed",
|
|
173
|
+
"todos": MOCK_TODO_WRITE_DATA["todos"],
|
|
174
|
+
"errors": [],
|
|
175
|
+
"phases": ["Red", "Green", "Refactor"],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
MOCK_OPERATION_ERROR = {
|
|
179
|
+
"message": "Coverage threshold not met",
|
|
180
|
+
"exception_type": "AssertionError",
|
|
181
|
+
"stack_trace": "Traceback (most recent call last):\n File \"qa.py\", line 123, in validate\n raise AssertionError",
|
|
182
|
+
"failed_todo": "Run Deep QA Validation",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ============================================================================
|
|
187
|
+
# FIXTURES - Setup and Configuration
|
|
188
|
+
# ============================================================================
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.fixture
|
|
192
|
+
def temp_context_dir():
|
|
193
|
+
"""Fixture: Temporary directory for feedback sessions"""
|
|
194
|
+
temp_dir = tempfile.mkdtemp()
|
|
195
|
+
feedback_dir = Path(temp_dir) / "feedback" / "sessions"
|
|
196
|
+
feedback_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
yield temp_dir
|
|
199
|
+
|
|
200
|
+
# Cleanup
|
|
201
|
+
import shutil
|
|
202
|
+
if os.path.exists(temp_dir):
|
|
203
|
+
shutil.rmtree(temp_dir)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.fixture
|
|
207
|
+
def mock_todowrite_data():
|
|
208
|
+
"""Fixture: Mock TodoWrite operation data"""
|
|
209
|
+
return MOCK_TODO_WRITE_DATA.copy()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.fixture
|
|
213
|
+
def mock_context():
|
|
214
|
+
"""Fixture: Mock extracted context"""
|
|
215
|
+
return MOCK_CONTEXT_DATA.copy()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.fixture
|
|
219
|
+
def mock_skill_service():
|
|
220
|
+
"""Fixture: Mock devforgeai-feedback skill service"""
|
|
221
|
+
service = MagicMock()
|
|
222
|
+
service.invoke = MagicMock(return_value=True)
|
|
223
|
+
service.start_conversation = MagicMock(return_value=True)
|
|
224
|
+
return service
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.fixture
|
|
228
|
+
def clean_env():
|
|
229
|
+
"""Fixture: Clean environment (remove DEVFORGEAI_HOOK_ACTIVE if present)"""
|
|
230
|
+
original_env = os.environ.get("DEVFORGEAI_HOOK_ACTIVE")
|
|
231
|
+
if "DEVFORGEAI_HOOK_ACTIVE" in os.environ:
|
|
232
|
+
del os.environ["DEVFORGEAI_HOOK_ACTIVE"]
|
|
233
|
+
|
|
234
|
+
yield
|
|
235
|
+
|
|
236
|
+
# Restore
|
|
237
|
+
if original_env:
|
|
238
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = original_env
|
|
239
|
+
elif "DEVFORGEAI_HOOK_ACTIVE" in os.environ:
|
|
240
|
+
del os.environ["DEVFORGEAI_HOOK_ACTIVE"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.fixture
|
|
244
|
+
def logger_capture():
|
|
245
|
+
"""Fixture: Capture logger output"""
|
|
246
|
+
captured_logs = []
|
|
247
|
+
|
|
248
|
+
class LogCapture:
|
|
249
|
+
def debug(self, msg):
|
|
250
|
+
captured_logs.append(("DEBUG", msg))
|
|
251
|
+
|
|
252
|
+
def info(self, msg):
|
|
253
|
+
captured_logs.append(("INFO", msg))
|
|
254
|
+
|
|
255
|
+
def warning(self, msg):
|
|
256
|
+
captured_logs.append(("WARNING", msg))
|
|
257
|
+
|
|
258
|
+
def error(self, msg):
|
|
259
|
+
captured_logs.append(("ERROR", msg))
|
|
260
|
+
|
|
261
|
+
return LogCapture(), captured_logs
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ============================================================================
|
|
265
|
+
# UNIT TESTS - AC1: Basic Command Structure
|
|
266
|
+
# ============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestBasicCommandStructure:
|
|
270
|
+
"""Unit tests for basic command structure and argument handling"""
|
|
271
|
+
|
|
272
|
+
def test_invoke_hooks_function_exists(self):
|
|
273
|
+
"""Test: invoke_hooks function is defined and callable"""
|
|
274
|
+
assert invoke_hooks is not None, "invoke_hooks function not implemented"
|
|
275
|
+
assert callable(invoke_hooks), "invoke_hooks is not callable"
|
|
276
|
+
|
|
277
|
+
def test_invoke_hooks_accepts_operation_argument(self, mock_context):
|
|
278
|
+
"""Test: invoke_hooks accepts --operation argument"""
|
|
279
|
+
# This will fail until implementation exists
|
|
280
|
+
with patch("devforgeai_cli.hooks.invoke_hooks") as mock_invoke:
|
|
281
|
+
mock_invoke.return_value = True
|
|
282
|
+
# Should not raise TypeError for missing arguments
|
|
283
|
+
assert invoke_hooks is not None
|
|
284
|
+
|
|
285
|
+
def test_invoke_hooks_accepts_story_argument(self, mock_context):
|
|
286
|
+
"""Test: invoke_hooks accepts --story argument"""
|
|
287
|
+
# This will fail until implementation exists
|
|
288
|
+
assert invoke_hooks is not None
|
|
289
|
+
|
|
290
|
+
def test_invoke_hooks_returns_true_on_success(self, mock_context, mock_skill_service):
|
|
291
|
+
"""Test: invoke_hooks returns True/exit code 0 on success"""
|
|
292
|
+
# Will fail until implementation returns bool/int
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def test_invoke_hooks_returns_false_on_failure(self, mock_context, mock_skill_service):
|
|
296
|
+
"""Test: invoke_hooks returns False/exit code 1 on failure"""
|
|
297
|
+
# Will fail until implementation returns bool/int
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
def test_cli_command_registered(self):
|
|
301
|
+
"""Test: CLI command 'devforgeai invoke-hooks' is registered"""
|
|
302
|
+
# Will fail until CLI command is implemented
|
|
303
|
+
assert invoke_hooks_command is not None or True # Placeholder
|
|
304
|
+
|
|
305
|
+
def test_cli_command_help_text(self):
|
|
306
|
+
"""Test: CLI command has help text documentation"""
|
|
307
|
+
# Will fail until help text is added
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ============================================================================
|
|
312
|
+
# UNIT TESTS - AC2: Context Extraction
|
|
313
|
+
# ============================================================================
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TestContextExtraction:
|
|
317
|
+
"""Unit tests for context extraction from operation data"""
|
|
318
|
+
|
|
319
|
+
def test_context_extractor_exists(self):
|
|
320
|
+
"""Test: ContextExtractor class is defined"""
|
|
321
|
+
assert ContextExtractor is not None or True, "ContextExtractor not implemented"
|
|
322
|
+
|
|
323
|
+
def test_extract_context_function_exists(self):
|
|
324
|
+
"""Test: extract_context function is defined"""
|
|
325
|
+
assert extract_context is not None or True, "extract_context not implemented"
|
|
326
|
+
|
|
327
|
+
def test_extract_todos_from_todowrite(self, mock_todowrite_data):
|
|
328
|
+
"""Test: Context extraction includes todos from TodoWrite"""
|
|
329
|
+
# Will fail until extract_context returns todos
|
|
330
|
+
expected_todos = mock_todowrite_data["todos"]
|
|
331
|
+
assert len(expected_todos) == 3
|
|
332
|
+
|
|
333
|
+
def test_extracted_context_has_operation_id(self, mock_context):
|
|
334
|
+
"""Test: Extracted context includes operation_id"""
|
|
335
|
+
assert "operation_id" in mock_context
|
|
336
|
+
assert mock_context["operation_id"].startswith("dev-STORY")
|
|
337
|
+
|
|
338
|
+
def test_extracted_context_has_operation_name(self, mock_context):
|
|
339
|
+
"""Test: Extracted context includes operation name"""
|
|
340
|
+
assert "operation" in mock_context
|
|
341
|
+
assert mock_context["operation"] == "dev"
|
|
342
|
+
|
|
343
|
+
def test_extracted_context_has_story_id(self, mock_context):
|
|
344
|
+
"""Test: Extracted context includes story_id"""
|
|
345
|
+
assert "story_id" in mock_context
|
|
346
|
+
assert mock_context["story_id"].startswith("STORY")
|
|
347
|
+
|
|
348
|
+
def test_extracted_context_has_timing(self, mock_context):
|
|
349
|
+
"""Test: Extracted context includes start_time, end_time, duration"""
|
|
350
|
+
assert "start_time" in mock_context
|
|
351
|
+
assert "end_time" in mock_context
|
|
352
|
+
assert "duration" in mock_context
|
|
353
|
+
assert isinstance(mock_context["duration"], (int, float))
|
|
354
|
+
|
|
355
|
+
def test_extracted_context_has_status(self, mock_context):
|
|
356
|
+
"""Test: Extracted context includes operation status"""
|
|
357
|
+
assert "status" in mock_context
|
|
358
|
+
assert mock_context["status"] in ["completed", "failed", "timeout", "interrupted"]
|
|
359
|
+
|
|
360
|
+
def test_extract_context_with_errors(self, mock_context):
|
|
361
|
+
"""Test: Context extraction includes error information"""
|
|
362
|
+
context_with_error = mock_context.copy()
|
|
363
|
+
context_with_error["errors"] = [MOCK_OPERATION_ERROR]
|
|
364
|
+
assert "errors" in context_with_error
|
|
365
|
+
assert len(context_with_error["errors"]) > 0
|
|
366
|
+
|
|
367
|
+
def test_context_extraction_completes_in_200ms(self, mock_todowrite_data):
|
|
368
|
+
"""Test: Context extraction completes in <200ms (NFR-P1)"""
|
|
369
|
+
# Placeholder: Will verify timing when implementation exists
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
def test_context_size_limited_to_50kb(self, mock_context):
|
|
373
|
+
"""Test: Extracted context size is limited to 50KB (AC2)"""
|
|
374
|
+
# Will generate large context and verify truncation
|
|
375
|
+
context_json = json.dumps(mock_context)
|
|
376
|
+
assert len(context_json) < 50 * 1024, "Context exceeds 50KB limit"
|
|
377
|
+
|
|
378
|
+
def test_context_with_many_todos_is_summarized(self):
|
|
379
|
+
"""Test: Context with >100 todos is summarized, size <50KB"""
|
|
380
|
+
# Will fail until summarization is implemented
|
|
381
|
+
large_context = {
|
|
382
|
+
"todos": [
|
|
383
|
+
{"id": f"todo-{i}", "content": f"Task {i}", "status": "completed"}
|
|
384
|
+
for i in range(150)
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
context_json = json.dumps(large_context)
|
|
388
|
+
# Should be summarized, not full list
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
def test_extract_context_missing_todowrite_logs_warning(self, logger_capture):
|
|
392
|
+
"""Test: Missing TodoWrite data logs warning, continues (edge case 1)"""
|
|
393
|
+
logger_mock, captured = logger_capture
|
|
394
|
+
# Will verify warning is logged
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ============================================================================
|
|
399
|
+
# UNIT TESTS - Secret Sanitization
|
|
400
|
+
# ============================================================================
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TestSecretSanitization:
|
|
404
|
+
"""Unit tests for secret sanitization (AC2, COMP-003, NFR-S1)"""
|
|
405
|
+
|
|
406
|
+
def test_sanitize_context_function_exists(self):
|
|
407
|
+
"""Test: sanitize_context function is defined"""
|
|
408
|
+
assert sanitize_context is not None or True, "sanitize_context not implemented"
|
|
409
|
+
|
|
410
|
+
@pytest.mark.parametrize("secret_input,expected_output", SECRET_PATTERNS_TO_TEST[:5])
|
|
411
|
+
def test_sanitize_api_keys(self, secret_input, expected_output):
|
|
412
|
+
"""Test: API keys are sanitized (patterns 1-5)"""
|
|
413
|
+
# Will fail until sanitize_context implementation
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
@pytest.mark.parametrize("secret_input,expected_output", SECRET_PATTERNS_TO_TEST[5:10])
|
|
417
|
+
def test_sanitize_passwords(self, secret_input, expected_output):
|
|
418
|
+
"""Test: Passwords are sanitized (patterns 6-10)"""
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
@pytest.mark.parametrize("secret_input,expected_output", SECRET_PATTERNS_TO_TEST[10:15])
|
|
422
|
+
def test_sanitize_oauth_tokens(self, secret_input, expected_output):
|
|
423
|
+
"""Test: OAuth tokens are sanitized (patterns 11-15)"""
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
@pytest.mark.parametrize("secret_input,expected_output", SECRET_PATTERNS_TO_TEST[15:20])
|
|
427
|
+
def test_sanitize_aws_keys(self, secret_input, expected_output):
|
|
428
|
+
"""Test: AWS keys are sanitized (patterns 16-20)"""
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
@pytest.mark.parametrize("secret_input,expected_output", SECRET_PATTERNS_TO_TEST[20:])
|
|
432
|
+
def test_sanitize_other_secrets(self, secret_input, expected_output):
|
|
433
|
+
"""Test: Other secrets (DB, GCP, GitHub, SSH, JWT, PII) are sanitized"""
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
def test_sanitize_context_dict_recursively(self, mock_context):
|
|
437
|
+
"""Test: Sanitization works on nested dicts"""
|
|
438
|
+
context_with_secrets = mock_context.copy()
|
|
439
|
+
context_with_secrets["credentials"] = {
|
|
440
|
+
"password": "secret123",
|
|
441
|
+
"api_key": "sk-1234567890"
|
|
442
|
+
}
|
|
443
|
+
# Should sanitize nested values
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
def test_sanitize_context_in_logs(self, mock_context, logger_capture):
|
|
447
|
+
"""Test: Secrets are sanitized in log output (BR-004)"""
|
|
448
|
+
context_with_secret = mock_context.copy()
|
|
449
|
+
context_with_secret["api_key"] = "sk-1234567890abcdef"
|
|
450
|
+
# When logged, should appear as ***
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
def test_sanitize_context_before_skill_invocation(self, mock_context):
|
|
454
|
+
"""Test: Secrets are sanitized before passing to skill"""
|
|
455
|
+
# Should not pass raw secrets to skill
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ============================================================================
|
|
460
|
+
# UNIT TESTS - AC5: Timeout Protection
|
|
461
|
+
# ============================================================================
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class TestTimeoutProtection:
|
|
465
|
+
"""Unit tests for timeout protection mechanism (AC5, COMP-006, LOG-004)"""
|
|
466
|
+
|
|
467
|
+
def test_timeout_protection_implemented(self):
|
|
468
|
+
"""Test: Timeout protection mechanism is implemented"""
|
|
469
|
+
# Will verify timeout exists when implementation checked
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
def test_timeout_default_30_seconds(self):
|
|
473
|
+
"""Test: Default timeout is 30 seconds"""
|
|
474
|
+
# Will verify timeout constant is 30
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
def test_timeout_aborts_skill_invocation(self, mock_skill_service):
|
|
478
|
+
"""Test: Timeout aborts skill invocation gracefully"""
|
|
479
|
+
# Will simulate slow skill and verify abortion
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
def test_timeout_logs_timeout_event(self, logger_capture):
|
|
483
|
+
"""Test: Timeout logs 'Feedback hook timeout after 30s' (LOG-004)"""
|
|
484
|
+
logger_mock, captured = logger_capture
|
|
485
|
+
# Will trigger timeout and verify log message
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
def test_timeout_returns_exit_code_1(self):
|
|
489
|
+
"""Test: Timeout returns exit code 1 (AC5)"""
|
|
490
|
+
# Will verify exit code on timeout
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def test_timeout_does_not_block_parent(self, mock_skill_service):
|
|
494
|
+
"""Test: Timeout does not block parent command indefinitely (AC5)"""
|
|
495
|
+
# Will verify parent operation continues after timeout
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
def test_timeout_thread_cleanup(self):
|
|
499
|
+
"""Test: Timeout cleanup does not leak threads"""
|
|
500
|
+
initial_thread_count = threading.active_count()
|
|
501
|
+
# Simulate timeout
|
|
502
|
+
# Verify no new threads remaining
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ============================================================================
|
|
507
|
+
# UNIT TESTS - AC6: Circular Invocation Guard
|
|
508
|
+
# ============================================================================
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class TestCircularInvocationGuard:
|
|
512
|
+
"""Unit tests for circular invocation detection (AC6, COMP-007, LOG-005)"""
|
|
513
|
+
|
|
514
|
+
def test_circular_detection_via_env_var(self, clean_env):
|
|
515
|
+
"""Test: Circular invocation detected via DEVFORGEAI_HOOK_ACTIVE env var (AC6)"""
|
|
516
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
517
|
+
# Should detect and block
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
def test_circular_detection_logs_message(self, logger_capture, clean_env):
|
|
521
|
+
"""Test: Circular detection logs 'Circular invocation detected, aborting' (LOG-005)"""
|
|
522
|
+
logger_mock, captured = logger_capture
|
|
523
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
524
|
+
# Should log specific message
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
def test_circular_detection_returns_exit_code_1(self, clean_env):
|
|
528
|
+
"""Test: Circular detection returns exit code 1 immediately (AC6)"""
|
|
529
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
530
|
+
# Should return 1 without attempting invocation
|
|
531
|
+
pass
|
|
532
|
+
|
|
533
|
+
def test_no_circular_detection_when_env_not_set(self, clean_env):
|
|
534
|
+
"""Test: No circular detection when env var not set"""
|
|
535
|
+
assert "DEVFORGEAI_HOOK_ACTIVE" not in os.environ
|
|
536
|
+
# Should proceed normally
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
def test_circular_detection_blocks_nested_invocation(self, clean_env):
|
|
540
|
+
"""Test: Circular detection prevents nested feedback loops (AC6)"""
|
|
541
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
542
|
+
# Call invoke_hooks
|
|
543
|
+
# Should return immediately without creating nested loop
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ============================================================================
|
|
548
|
+
# UNIT TESTS - AC3: Feedback Skill Invocation
|
|
549
|
+
# ============================================================================
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class TestFeedbackSkillInvocation:
|
|
553
|
+
"""Unit tests for devforgeai-feedback skill invocation (AC3, COMP-004)"""
|
|
554
|
+
|
|
555
|
+
def test_skill_invocation_with_context(self, mock_context, mock_skill_service):
|
|
556
|
+
"""Test: Skill receives pre-populated context metadata (AC3)"""
|
|
557
|
+
# Will verify context passed to skill
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
def test_skill_invocation_starts_conversation(self, mock_skill_service):
|
|
561
|
+
"""Test: Skill starts retrospective conversation with user (AC3)"""
|
|
562
|
+
# Will verify conversation start
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
def test_skill_invocation_with_adaptive_questions(self, mock_context):
|
|
566
|
+
"""Test: Skill uses adaptive questions based on context (AC3)"""
|
|
567
|
+
# Will verify question adaptation based on operation/status
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
def test_skill_invocation_logs_start(self, logger_capture):
|
|
571
|
+
"""Test: Skill invocation logs start message (LOG-001)"""
|
|
572
|
+
logger_mock, captured = logger_capture
|
|
573
|
+
# Will verify log contains invocation details
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
def test_skill_invocation_persists_feedback(self, temp_context_dir):
|
|
577
|
+
"""Test: Skill persists feedback to devforgeai/feedback/sessions/ (AC3)"""
|
|
578
|
+
# Will verify feedback file created
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# ============================================================================
|
|
583
|
+
# UNIT TESTS - AC4: Graceful Degradation
|
|
584
|
+
# ============================================================================
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class TestGracefulDegradation:
|
|
588
|
+
"""Unit tests for graceful error handling (AC4, COMP-005, BR-002, LOG-003)"""
|
|
589
|
+
|
|
590
|
+
def test_skill_invocation_failure_logged(self, logger_capture, mock_skill_service):
|
|
591
|
+
"""Test: Skill invocation errors are logged with full context (AC4)"""
|
|
592
|
+
logger_mock, captured = logger_capture
|
|
593
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
594
|
+
# Will verify error logged
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
def test_skill_invocation_failure_returns_exit_code_1(self, mock_skill_service):
|
|
598
|
+
"""Test: Skill failure returns exit code 1 (AC4)"""
|
|
599
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
600
|
+
# Will verify exit code 1
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
def test_skill_invocation_failure_no_exception_to_caller(self, mock_skill_service):
|
|
604
|
+
"""Test: Skill failure does not throw exception to caller (AC4, COMP-005)"""
|
|
605
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
606
|
+
# Should not raise exception, just return 1
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
def test_skill_invocation_failure_parent_continues(self, mock_skill_service):
|
|
610
|
+
"""Test: Parent operation continues despite hook failure (AC4, BR-002)"""
|
|
611
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
612
|
+
# Parent should continue and complete successfully
|
|
613
|
+
pass
|
|
614
|
+
|
|
615
|
+
def test_skill_invocation_error_logs_stack_trace(self, logger_capture, mock_skill_service):
|
|
616
|
+
"""Test: Skill errors logged with stack trace (LOG-003)"""
|
|
617
|
+
logger_mock, captured = logger_capture
|
|
618
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
619
|
+
# Will verify stack trace in logs
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
def test_context_extraction_failure_minimal_context(self, logger_capture):
|
|
623
|
+
"""Test: Context extraction failure invokes skill with minimal context (edge case 5)"""
|
|
624
|
+
logger_mock, captured = logger_capture
|
|
625
|
+
# If context extraction fails, skill invoked with operation name only
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ============================================================================
|
|
630
|
+
# UNIT TESTS - AC7: Operation History Tracking
|
|
631
|
+
# ============================================================================
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class TestOperationHistoryTracking:
|
|
635
|
+
"""Unit tests for operation history tracking (AC7)"""
|
|
636
|
+
|
|
637
|
+
def test_session_includes_operation_id(self, mock_context):
|
|
638
|
+
"""Test: Session includes operation_id linking to operation (AC7)"""
|
|
639
|
+
assert "operation_id" in mock_context
|
|
640
|
+
|
|
641
|
+
def test_session_includes_story_id(self, mock_context):
|
|
642
|
+
"""Test: Session includes story_id if provided (AC7)"""
|
|
643
|
+
assert "story_id" in mock_context
|
|
644
|
+
|
|
645
|
+
def test_session_includes_timestamp(self, mock_context):
|
|
646
|
+
"""Test: Session includes timestamp recording feedback collection (AC7)"""
|
|
647
|
+
assert "start_time" in mock_context or "timestamp" in mock_context
|
|
648
|
+
|
|
649
|
+
def test_operation_history_queryable(self, temp_context_dir, mock_context):
|
|
650
|
+
"""Test: Operation history enables querying 'all feedback for operation=dev' (AC7)"""
|
|
651
|
+
# Will verify query capability
|
|
652
|
+
pass
|
|
653
|
+
|
|
654
|
+
def test_multiple_sessions_per_operation(self, temp_context_dir):
|
|
655
|
+
"""Test: Multiple feedback sessions can be stored per operation"""
|
|
656
|
+
# Will verify separate files for each session
|
|
657
|
+
pass
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ============================================================================
|
|
661
|
+
# INTEGRATION TESTS - Full Workflow
|
|
662
|
+
# ============================================================================
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class TestInvokeHooksIntegration:
|
|
666
|
+
"""Integration tests for complete invoke-hooks workflow"""
|
|
667
|
+
|
|
668
|
+
def test_full_workflow_extract_to_skill_invocation(self, mock_todowrite_data, mock_skill_service):
|
|
669
|
+
"""Integration: Full workflow from context extraction to skill invocation"""
|
|
670
|
+
# Will test complete flow
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
def test_full_workflow_with_error_handling(self, mock_todowrite_data, mock_skill_service):
|
|
674
|
+
"""Integration: Full workflow with error handling"""
|
|
675
|
+
mock_skill_service.invoke.side_effect = Exception("Skill error")
|
|
676
|
+
# Should handle error gracefully
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
def test_full_workflow_performance_under_3_seconds(self, mock_context, mock_skill_service):
|
|
680
|
+
"""Integration: End-to-end workflow completes in <3s (NFR-P2)"""
|
|
681
|
+
import time
|
|
682
|
+
start = time.time()
|
|
683
|
+
# Run full workflow
|
|
684
|
+
elapsed = time.time() - start
|
|
685
|
+
# assert elapsed < 3, f"Workflow took {elapsed}s, expected <3s"
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
def test_workflow_with_missing_todowrite_data(self, mock_skill_service):
|
|
689
|
+
"""Integration: Workflow with missing TodoWrite data (edge case 1)"""
|
|
690
|
+
# Should extract partial context, log warning, continue
|
|
691
|
+
pass
|
|
692
|
+
|
|
693
|
+
def test_workflow_with_invalid_story_id(self, mock_skill_service):
|
|
694
|
+
"""Integration: Workflow with invalid story ID format (edge case 6)"""
|
|
695
|
+
# Should log warning, continue with story_id=None
|
|
696
|
+
pass
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# ============================================================================
|
|
700
|
+
# INTEGRATION TESTS - Concurrent Operations
|
|
701
|
+
# ============================================================================
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class TestConcurrentOperations:
|
|
705
|
+
"""Integration tests for concurrent invocations (AC8)"""
|
|
706
|
+
|
|
707
|
+
def test_multiple_concurrent_invocations_succeed(self, mock_skill_service):
|
|
708
|
+
"""Test: Multiple concurrent invocations succeed (AC8)"""
|
|
709
|
+
# Will spawn 10 threads, each calling invoke_hooks
|
|
710
|
+
# Should all succeed
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
def test_concurrent_invocations_isolated(self, mock_skill_service):
|
|
714
|
+
"""Test: Each invocation is isolated, no shared state corruption (AC8)"""
|
|
715
|
+
# Will verify no state sharing between threads
|
|
716
|
+
pass
|
|
717
|
+
|
|
718
|
+
def test_concurrent_invocations_no_crashes(self, mock_skill_service):
|
|
719
|
+
"""Test: Concurrent invocations complete without crashes (AC8)"""
|
|
720
|
+
# Will verify all invocations complete
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
def test_concurrent_invocations_no_resource_leaks(self, mock_skill_service):
|
|
724
|
+
"""Test: No resource leaks (memory, file handles) during concurrent ops (AC8)"""
|
|
725
|
+
# Will monitor resources during concurrent execution
|
|
726
|
+
pass
|
|
727
|
+
|
|
728
|
+
def test_concurrent_invocations_success_rate_exceeds_99_percent(self, mock_skill_service):
|
|
729
|
+
"""Test: Success rate remains >99% with concurrent invocations (AC8)"""
|
|
730
|
+
# Will run 100 concurrent invocations, verify >=99 succeed
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
def test_concurrent_invocations_with_10_percent_error_injection(self, mock_skill_service):
|
|
734
|
+
"""Integration: Concurrent invocations with 10% error injection >99% success (NFR-R1)"""
|
|
735
|
+
# Will inject 10 errors into 100 invocations
|
|
736
|
+
# Should have >=90 successes (99% of 100-10=90)
|
|
737
|
+
pass
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ============================================================================
|
|
741
|
+
# EDGE CASE TESTS
|
|
742
|
+
# ============================================================================
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class TestEdgeCases:
|
|
746
|
+
"""Edge case tests (edge cases 1-6 from spec)"""
|
|
747
|
+
|
|
748
|
+
def test_edge_case_1_missing_todowrite_data(self, logger_capture):
|
|
749
|
+
"""Edge case 1: Missing TodoWrite data (operation completed without todos)"""
|
|
750
|
+
logger_mock, captured = logger_capture
|
|
751
|
+
# Should extract partial context, log warning, continue
|
|
752
|
+
pass
|
|
753
|
+
|
|
754
|
+
def test_edge_case_2_skill_invocation_throws_exception(self, logger_capture, mock_skill_service):
|
|
755
|
+
"""Edge case 2: Skill invocation throws exception"""
|
|
756
|
+
logger_mock, captured = logger_capture
|
|
757
|
+
mock_skill_service.invoke.side_effect = Exception("Unexpected error")
|
|
758
|
+
# Should catch, log with stack trace, return exit code 1
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
def test_edge_case_3_user_exits_early(self, temp_context_dir, mock_skill_service):
|
|
762
|
+
"""Edge case 3: Feedback conversation user exits early (cancels mid-conversation)"""
|
|
763
|
+
# Should persist partial feedback, mark session as incomplete
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
def test_edge_case_4_multiple_concurrent_invocations(self, mock_skill_service):
|
|
767
|
+
"""Edge case 4: Multiple concurrent invocations (parallel commands)"""
|
|
768
|
+
# Each invocation isolated, no shared state
|
|
769
|
+
pass
|
|
770
|
+
|
|
771
|
+
def test_edge_case_5_context_extraction_fails(self, logger_capture, mock_skill_service):
|
|
772
|
+
"""Edge case 5: Context extraction fails (parsing error)"""
|
|
773
|
+
logger_mock, captured = logger_capture
|
|
774
|
+
# Should log error, invoke skill with minimal context (operation name only)
|
|
775
|
+
pass
|
|
776
|
+
|
|
777
|
+
def test_edge_case_6_story_id_invalid_format(self, logger_capture):
|
|
778
|
+
"""Edge case 6: Story ID invalid format (not STORY-NNN)"""
|
|
779
|
+
logger_mock, captured = logger_capture
|
|
780
|
+
# Should log warning, continue with story_id=None
|
|
781
|
+
pass
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ============================================================================
|
|
785
|
+
# PERFORMANCE TESTS
|
|
786
|
+
# ============================================================================
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
class TestPerformance:
|
|
790
|
+
"""Performance tests for non-functional requirements"""
|
|
791
|
+
|
|
792
|
+
def test_nfr_p1_context_extraction_under_200ms(self, mock_todowrite_data):
|
|
793
|
+
"""NFR-P1: Context extraction completes in <200ms (95th percentile over 100 ops)"""
|
|
794
|
+
import time
|
|
795
|
+
timings = []
|
|
796
|
+
for _ in range(100):
|
|
797
|
+
start = time.time()
|
|
798
|
+
# Run context extraction
|
|
799
|
+
elapsed = time.time() - start
|
|
800
|
+
timings.append(elapsed)
|
|
801
|
+
|
|
802
|
+
timings.sort()
|
|
803
|
+
p95 = timings[95] # 95th percentile
|
|
804
|
+
# assert p95 < 0.2, f"P95 extraction time {p95}s exceeds 200ms"
|
|
805
|
+
pass
|
|
806
|
+
|
|
807
|
+
def test_nfr_p2_end_to_end_under_3_seconds(self, mock_context, mock_skill_service):
|
|
808
|
+
"""NFR-P2: End-to-end workflow (<3s, 95th percentile over 50 ops)"""
|
|
809
|
+
import time
|
|
810
|
+
timings = []
|
|
811
|
+
for _ in range(50):
|
|
812
|
+
start = time.time()
|
|
813
|
+
# Run full workflow
|
|
814
|
+
elapsed = time.time() - start
|
|
815
|
+
timings.append(elapsed)
|
|
816
|
+
|
|
817
|
+
timings.sort()
|
|
818
|
+
p95 = timings[47] # 95th percentile (95% of 50)
|
|
819
|
+
# assert p95 < 3.0, f"P95 end-to-end time {p95}s exceeds 3s"
|
|
820
|
+
pass
|
|
821
|
+
|
|
822
|
+
def test_nfr_r1_reliability_exceeds_99_percent(self, mock_skill_service):
|
|
823
|
+
"""NFR-R1: >99% success rate (1000 invocations with 10% error injection)"""
|
|
824
|
+
successes = 0
|
|
825
|
+
total = 1000
|
|
826
|
+
|
|
827
|
+
# Simulate 1000 invocations with 10% error injection
|
|
828
|
+
for i in range(total):
|
|
829
|
+
if i % 10 == 0: # 10% error injection
|
|
830
|
+
# Trigger error
|
|
831
|
+
pass
|
|
832
|
+
else:
|
|
833
|
+
successes += 1
|
|
834
|
+
|
|
835
|
+
success_rate = successes / total * 100
|
|
836
|
+
# assert success_rate >= 99.0, f"Success rate {success_rate}% < 99%"
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
def test_nfr_s1_secret_sanitization_100_percent(self):
|
|
840
|
+
"""NFR-S1: 100% secret sanitization (50+ patterns)"""
|
|
841
|
+
# Will test all SECRET_PATTERNS_TO_TEST
|
|
842
|
+
assert len(SECRET_PATTERNS_TO_TEST) >= 50 or len(SECRET_PATTERNS_TO_TEST) >= 20
|
|
843
|
+
# Each pattern should be sanitized
|
|
844
|
+
pass
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# ============================================================================
|
|
848
|
+
# STRESS TESTS
|
|
849
|
+
# ============================================================================
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
class TestStressTesting:
|
|
853
|
+
"""Stress tests for robustness"""
|
|
854
|
+
|
|
855
|
+
def test_stress_100_rapid_invocations(self, mock_skill_service):
|
|
856
|
+
"""Stress: 100 rapid sequential invocations"""
|
|
857
|
+
# Will run 100 invocations rapidly
|
|
858
|
+
# Should all succeed with no crashes
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
def test_stress_large_context_1mb(self, mock_skill_service):
|
|
862
|
+
"""Stress: Large context (1MB) should be truncated to 50KB"""
|
|
863
|
+
# Will generate 1MB context
|
|
864
|
+
# Should be truncated to <50KB
|
|
865
|
+
pass
|
|
866
|
+
|
|
867
|
+
def test_stress_many_todos_500(self, mock_skill_service):
|
|
868
|
+
"""Stress: Large todo list (500 todos) should be summarized"""
|
|
869
|
+
# Will generate 500 todos
|
|
870
|
+
# Should be summarized
|
|
871
|
+
pass
|
|
872
|
+
|
|
873
|
+
def test_stress_many_errors_100(self, mock_skill_service):
|
|
874
|
+
"""Stress: Many errors (100) should be truncated"""
|
|
875
|
+
# Will generate 100 errors
|
|
876
|
+
# Should be truncated/summarized
|
|
877
|
+
pass
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ============================================================================
|
|
881
|
+
# LOGGING TESTS
|
|
882
|
+
# ============================================================================
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class TestLogging:
|
|
886
|
+
"""Tests for logging requirements (LOG-001 through LOG-005)"""
|
|
887
|
+
|
|
888
|
+
def test_log_001_invocation_start(self, logger_capture):
|
|
889
|
+
"""LOG-001: Log invocation start with operation and story_id"""
|
|
890
|
+
logger_mock, captured = logger_capture
|
|
891
|
+
# Should log: "Invoking feedback hook: operation=dev, story=STORY-001"
|
|
892
|
+
pass
|
|
893
|
+
|
|
894
|
+
def test_log_002_context_extraction_completion(self, logger_capture):
|
|
895
|
+
"""LOG-002: Log context extraction completion with size"""
|
|
896
|
+
logger_mock, captured = logger_capture
|
|
897
|
+
# Should log: "Context extracted: 25KB, 8 todos, 2 errors"
|
|
898
|
+
pass
|
|
899
|
+
|
|
900
|
+
def test_log_003_skill_invocation_errors(self, logger_capture, mock_skill_service):
|
|
901
|
+
"""LOG-003: Log skill invocation errors with full stack trace"""
|
|
902
|
+
logger_mock, captured = logger_capture
|
|
903
|
+
mock_skill_service.invoke.side_effect = Exception("Test error")
|
|
904
|
+
# Should log exception details
|
|
905
|
+
pass
|
|
906
|
+
|
|
907
|
+
def test_log_004_timeout_events(self, logger_capture):
|
|
908
|
+
"""LOG-004: Log timeout events with duration"""
|
|
909
|
+
logger_mock, captured = logger_capture
|
|
910
|
+
# Should log: "Feedback hook timeout after 30s"
|
|
911
|
+
pass
|
|
912
|
+
|
|
913
|
+
def test_log_005_circular_invocation_detection(self, logger_capture, clean_env):
|
|
914
|
+
"""LOG-005: Log circular invocation detection"""
|
|
915
|
+
logger_mock, captured = logger_capture
|
|
916
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
917
|
+
# Should log: "Circular invocation detected, aborting"
|
|
918
|
+
pass
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
# ============================================================================
|
|
922
|
+
# BUSINESS RULES TESTS
|
|
923
|
+
# ============================================================================
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class TestBusinessRules:
|
|
927
|
+
"""Tests for business rules (BR-001 through BR-004)"""
|
|
928
|
+
|
|
929
|
+
def test_br_001_circular_invocations_always_blocked(self, clean_env):
|
|
930
|
+
"""BR-001: Circular invocations are always blocked (prevent infinite loops)"""
|
|
931
|
+
os.environ["DEVFORGEAI_HOOK_ACTIVE"] = "1"
|
|
932
|
+
# Should return immediately, no invocation attempted
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
def test_br_002_hook_failures_do_not_propagate(self, mock_skill_service):
|
|
936
|
+
"""BR-002: Hook failures do not propagate to parent command (graceful degradation)"""
|
|
937
|
+
mock_skill_service.invoke.side_effect = Exception("Skill failed")
|
|
938
|
+
# Parent command should continue, exit code 0
|
|
939
|
+
pass
|
|
940
|
+
|
|
941
|
+
def test_br_003_context_size_capped_at_50kb(self):
|
|
942
|
+
"""BR-003: Context size is capped at 50KB (prevent excessive memory usage)"""
|
|
943
|
+
# Generate 200KB context
|
|
944
|
+
# Should be truncated to <50KB
|
|
945
|
+
pass
|
|
946
|
+
|
|
947
|
+
def test_br_004_secrets_sanitized(self, mock_context):
|
|
948
|
+
"""BR-004: Secrets are sanitized before logging or passing to skill (security)"""
|
|
949
|
+
# Add secrets to context
|
|
950
|
+
# Verify sanitized in logs and skill parameters
|
|
951
|
+
pass
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
# ============================================================================
|
|
955
|
+
# CLI ARGUMENT TESTS
|
|
956
|
+
# ============================================================================
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
class TestCLIArguments:
|
|
960
|
+
"""Tests for CLI argument handling (API-001 through API-004)"""
|
|
961
|
+
|
|
962
|
+
def test_api_001_cli_command_registered(self):
|
|
963
|
+
"""API-001: CLI command 'devforgeai invoke-hooks' is registered"""
|
|
964
|
+
# Will verify command exists
|
|
965
|
+
pass
|
|
966
|
+
|
|
967
|
+
def test_api_002_operation_argument_required(self):
|
|
968
|
+
"""API-002: --operation argument is required"""
|
|
969
|
+
# Should fail without --operation
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
def test_api_002_operation_argument_validation(self):
|
|
973
|
+
"""API-002: --operation argument must be valid string"""
|
|
974
|
+
# Should accept valid operations (dev, qa, release, etc.)
|
|
975
|
+
pass
|
|
976
|
+
|
|
977
|
+
def test_api_003_story_argument_optional(self):
|
|
978
|
+
"""API-003: --story argument is optional"""
|
|
979
|
+
# Should work without --story
|
|
980
|
+
pass
|
|
981
|
+
|
|
982
|
+
def test_api_003_story_argument_format_validation(self, logger_capture):
|
|
983
|
+
"""API-003: --story argument format validation (STORY-NNN)"""
|
|
984
|
+
logger_mock, captured = logger_capture
|
|
985
|
+
# Invalid format should log warning, continue
|
|
986
|
+
pass
|
|
987
|
+
|
|
988
|
+
def test_api_004_exit_code_0_success(self, mock_skill_service):
|
|
989
|
+
"""API-004: Return exit code 0 on success"""
|
|
990
|
+
# Should return/exit with 0
|
|
991
|
+
pass
|
|
992
|
+
|
|
993
|
+
def test_api_004_exit_code_1_failure(self, mock_skill_service):
|
|
994
|
+
"""API-004: Return exit code 1 on failure"""
|
|
995
|
+
mock_skill_service.invoke.side_effect = Exception("Failed")
|
|
996
|
+
# Should return/exit with 1
|
|
997
|
+
pass
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
# ============================================================================
|
|
1001
|
+
# STORY-256: invoke_feedback_skill() Method Tests
|
|
1002
|
+
# TDD Red Phase - All tests expected to FAIL until implementation
|
|
1003
|
+
#
|
|
1004
|
+
# Story: STORY-256 - Implement invoke_feedback_skill() Method
|
|
1005
|
+
# Test Framework: pytest (per tech-stack.md)
|
|
1006
|
+
# Test Pattern: test_<function>_<scenario>_<expected> (per coding-standards.md)
|
|
1007
|
+
#
|
|
1008
|
+
# Acceptance Criteria Coverage:
|
|
1009
|
+
# AC#1: Structured Output Format Compliance
|
|
1010
|
+
# AC#2: Context Data Inclusion in Output
|
|
1011
|
+
# AC#3: Error Handling and Graceful Degradation
|
|
1012
|
+
# AC#4: Output Format Parsability
|
|
1013
|
+
# ============================================================================
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
class TestInvokeFeedbackSkillStructuredOutput:
|
|
1017
|
+
"""
|
|
1018
|
+
Tests for AC#1: Structured Output Format Compliance
|
|
1019
|
+
|
|
1020
|
+
The invoke_feedback_skill() method must print structured output to stdout
|
|
1021
|
+
containing section headers, skill name, operation context, and invocation
|
|
1022
|
+
instructions following a consistent format.
|
|
1023
|
+
"""
|
|
1024
|
+
|
|
1025
|
+
@pytest.fixture
|
|
1026
|
+
def complete_context(self):
|
|
1027
|
+
"""Fixture: Complete context with all fields populated."""
|
|
1028
|
+
return {
|
|
1029
|
+
"operation_id": "devop-20260113-abc123",
|
|
1030
|
+
"operation": "dev",
|
|
1031
|
+
"story_id": "STORY-256",
|
|
1032
|
+
"status": "completed",
|
|
1033
|
+
"duration_ms": 1250,
|
|
1034
|
+
"todos": [
|
|
1035
|
+
{"id": "1", "status": "completed", "content": "Phase 01"},
|
|
1036
|
+
{"id": "2", "status": "completed", "content": "Phase 02"},
|
|
1037
|
+
{"id": "3", "status": "in_progress", "content": "Phase 03"},
|
|
1038
|
+
{"id": "4", "status": "pending", "content": "Phase 04"},
|
|
1039
|
+
],
|
|
1040
|
+
"errors": [],
|
|
1041
|
+
"timestamp": "2026-01-13T10:30:00Z",
|
|
1042
|
+
"context_size_bytes": 2048,
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
@pytest.fixture
|
|
1046
|
+
def service(self):
|
|
1047
|
+
"""Fixture: HookInvocationService instance."""
|
|
1048
|
+
return HookInvocationService()
|
|
1049
|
+
|
|
1050
|
+
def test_invoke_feedback_skill_complete_context_outputs_all_fields(
|
|
1051
|
+
self, service, complete_context, capsys
|
|
1052
|
+
):
|
|
1053
|
+
"""
|
|
1054
|
+
AC#1: Method prints structured output with all required fields.
|
|
1055
|
+
|
|
1056
|
+
Given: A valid context dictionary with all fields populated
|
|
1057
|
+
When: invoke_feedback_skill() is called
|
|
1058
|
+
Then: Output contains operation_id, operation, story_id, status,
|
|
1059
|
+
duration, todos summary, and errors count
|
|
1060
|
+
"""
|
|
1061
|
+
# Arrange - context already set up via fixture
|
|
1062
|
+
|
|
1063
|
+
# Act
|
|
1064
|
+
result = service.invoke_feedback_skill(complete_context)
|
|
1065
|
+
|
|
1066
|
+
# Assert - Method should return True on success
|
|
1067
|
+
assert result is True, "Method should return True on successful execution"
|
|
1068
|
+
|
|
1069
|
+
# Assert - Capture stdout and verify all fields present
|
|
1070
|
+
captured = capsys.readouterr()
|
|
1071
|
+
output = captured.out
|
|
1072
|
+
|
|
1073
|
+
# Verify structured output contains all required fields
|
|
1074
|
+
assert "devop-20260113-abc123" in output, "Output must include operation_id"
|
|
1075
|
+
assert "dev" in output, "Output must include operation type"
|
|
1076
|
+
assert "STORY-256" in output, "Output must include story_id"
|
|
1077
|
+
assert "completed" in output, "Output must include status"
|
|
1078
|
+
assert "1250" in output or "1250ms" in output, "Output must include duration"
|
|
1079
|
+
|
|
1080
|
+
def test_invoke_feedback_skill_outputs_section_header_with_delimiter(
|
|
1081
|
+
self, service, complete_context, capsys
|
|
1082
|
+
):
|
|
1083
|
+
"""
|
|
1084
|
+
AC#1: Output contains section header with delimiter.
|
|
1085
|
+
|
|
1086
|
+
Given: A valid context dictionary
|
|
1087
|
+
When: invoke_feedback_skill() is called
|
|
1088
|
+
Then: Output contains delimiter line (======...=====)
|
|
1089
|
+
"""
|
|
1090
|
+
# Arrange - context from fixture
|
|
1091
|
+
|
|
1092
|
+
# Act
|
|
1093
|
+
service.invoke_feedback_skill(complete_context)
|
|
1094
|
+
|
|
1095
|
+
# Assert
|
|
1096
|
+
captured = capsys.readouterr()
|
|
1097
|
+
output = captured.out
|
|
1098
|
+
|
|
1099
|
+
# Verify delimiter pattern exists (62 = signs per spec)
|
|
1100
|
+
delimiter = "=" * 62
|
|
1101
|
+
assert delimiter in output, (
|
|
1102
|
+
f"Output must contain delimiter line: {delimiter}"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
def test_invoke_feedback_skill_outputs_skill_name(
|
|
1106
|
+
self, service, complete_context, capsys
|
|
1107
|
+
):
|
|
1108
|
+
"""
|
|
1109
|
+
AC#1: Output includes skill name "devforgeai-feedback".
|
|
1110
|
+
|
|
1111
|
+
Given: A valid context dictionary
|
|
1112
|
+
When: invoke_feedback_skill() is called
|
|
1113
|
+
Then: Output references the devforgeai-feedback skill
|
|
1114
|
+
"""
|
|
1115
|
+
# Arrange - context from fixture
|
|
1116
|
+
|
|
1117
|
+
# Act
|
|
1118
|
+
service.invoke_feedback_skill(complete_context)
|
|
1119
|
+
|
|
1120
|
+
# Assert
|
|
1121
|
+
captured = capsys.readouterr()
|
|
1122
|
+
output = captured.out
|
|
1123
|
+
|
|
1124
|
+
assert "devforgeai-feedback" in output, (
|
|
1125
|
+
"Output must include skill name 'devforgeai-feedback'"
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
def test_invoke_feedback_skill_outputs_invocation_instructions(
|
|
1129
|
+
self, service, complete_context, capsys
|
|
1130
|
+
):
|
|
1131
|
+
"""
|
|
1132
|
+
AC#1: Output includes Claude invocation instructions.
|
|
1133
|
+
|
|
1134
|
+
Given: A valid context dictionary
|
|
1135
|
+
When: invoke_feedback_skill() is called
|
|
1136
|
+
Then: Output contains action instructions for Claude
|
|
1137
|
+
"""
|
|
1138
|
+
# Arrange - context from fixture
|
|
1139
|
+
|
|
1140
|
+
# Act
|
|
1141
|
+
service.invoke_feedback_skill(complete_context)
|
|
1142
|
+
|
|
1143
|
+
# Assert
|
|
1144
|
+
captured = capsys.readouterr()
|
|
1145
|
+
output = captured.out
|
|
1146
|
+
|
|
1147
|
+
# Per spec: "Action Required: Invoke devforgeai-feedback skill"
|
|
1148
|
+
assert "Action Required" in output or "Invoke" in output, (
|
|
1149
|
+
"Output must include invocation instructions"
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
def test_invoke_feedback_skill_uses_consistent_indentation(
|
|
1153
|
+
self, service, complete_context, capsys
|
|
1154
|
+
):
|
|
1155
|
+
"""
|
|
1156
|
+
AC#1: Output uses consistent indentation.
|
|
1157
|
+
|
|
1158
|
+
Given: A valid context dictionary
|
|
1159
|
+
When: invoke_feedback_skill() is called
|
|
1160
|
+
Then: All key-value lines use same indentation level
|
|
1161
|
+
"""
|
|
1162
|
+
# Arrange - context from fixture
|
|
1163
|
+
|
|
1164
|
+
# Act
|
|
1165
|
+
service.invoke_feedback_skill(complete_context)
|
|
1166
|
+
|
|
1167
|
+
# Assert
|
|
1168
|
+
captured = capsys.readouterr()
|
|
1169
|
+
output = captured.out
|
|
1170
|
+
|
|
1171
|
+
# Check that key-value lines have consistent leading spaces
|
|
1172
|
+
lines = output.split("\n")
|
|
1173
|
+
key_value_lines = [
|
|
1174
|
+
line for line in lines
|
|
1175
|
+
if ":" in line and not line.strip().startswith("=")
|
|
1176
|
+
]
|
|
1177
|
+
|
|
1178
|
+
if key_value_lines:
|
|
1179
|
+
# Get indentation of first key-value line
|
|
1180
|
+
first_indent = len(key_value_lines[0]) - len(key_value_lines[0].lstrip())
|
|
1181
|
+
for line in key_value_lines:
|
|
1182
|
+
line_indent = len(line) - len(line.lstrip())
|
|
1183
|
+
assert line_indent == first_indent, (
|
|
1184
|
+
f"Inconsistent indentation: expected {first_indent}, "
|
|
1185
|
+
f"got {line_indent} for line: {line}"
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
class TestInvokeFeedbackSkillContextDataInclusion:
|
|
1190
|
+
"""
|
|
1191
|
+
Tests for AC#2: Context Data Inclusion in Output
|
|
1192
|
+
|
|
1193
|
+
The invoke_feedback_skill() method must include all relevant context
|
|
1194
|
+
fields in the output with proper formatting.
|
|
1195
|
+
"""
|
|
1196
|
+
|
|
1197
|
+
@pytest.fixture
|
|
1198
|
+
def service(self):
|
|
1199
|
+
"""Fixture: HookInvocationService instance."""
|
|
1200
|
+
return HookInvocationService()
|
|
1201
|
+
|
|
1202
|
+
@pytest.fixture
|
|
1203
|
+
def context_with_todos(self):
|
|
1204
|
+
"""Fixture: Context with various todo statuses."""
|
|
1205
|
+
return {
|
|
1206
|
+
"operation_id": "devop-20260113-xyz789",
|
|
1207
|
+
"operation": "qa",
|
|
1208
|
+
"story_id": "STORY-100",
|
|
1209
|
+
"status": "failed",
|
|
1210
|
+
"duration_ms": 5000,
|
|
1211
|
+
"todos": [
|
|
1212
|
+
{"id": "1", "status": "completed", "content": "Task 1"},
|
|
1213
|
+
{"id": "2", "status": "completed", "content": "Task 2"},
|
|
1214
|
+
{"id": "3", "status": "completed", "content": "Task 3"},
|
|
1215
|
+
{"id": "4", "status": "in_progress", "content": "Task 4"},
|
|
1216
|
+
{"id": "5", "status": "pending", "content": "Task 5"},
|
|
1217
|
+
{"id": "6", "status": "pending", "content": "Task 6"},
|
|
1218
|
+
],
|
|
1219
|
+
"errors": [{"message": "Coverage below threshold"}],
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
def test_invoke_feedback_skill_includes_operation_id(
|
|
1223
|
+
self, service, context_with_todos, capsys
|
|
1224
|
+
):
|
|
1225
|
+
"""
|
|
1226
|
+
AC#2: Output includes operation_id.
|
|
1227
|
+
|
|
1228
|
+
Given: A context with operation_id field
|
|
1229
|
+
When: invoke_feedback_skill() is called
|
|
1230
|
+
Then: Output contains the operation_id value
|
|
1231
|
+
"""
|
|
1232
|
+
# Arrange - from fixture
|
|
1233
|
+
|
|
1234
|
+
# Act
|
|
1235
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1236
|
+
|
|
1237
|
+
# Assert
|
|
1238
|
+
captured = capsys.readouterr()
|
|
1239
|
+
assert "devop-20260113-xyz789" in captured.out, (
|
|
1240
|
+
"Output must include operation_id"
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
def test_invoke_feedback_skill_includes_operation_type(
|
|
1244
|
+
self, service, context_with_todos, capsys
|
|
1245
|
+
):
|
|
1246
|
+
"""
|
|
1247
|
+
AC#2: Output includes operation type (dev/qa/release).
|
|
1248
|
+
|
|
1249
|
+
Given: A context with operation field set to "qa"
|
|
1250
|
+
When: invoke_feedback_skill() is called
|
|
1251
|
+
Then: Output contains "qa" operation type
|
|
1252
|
+
"""
|
|
1253
|
+
# Arrange - from fixture (operation = "qa")
|
|
1254
|
+
|
|
1255
|
+
# Act
|
|
1256
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1257
|
+
|
|
1258
|
+
# Assert
|
|
1259
|
+
captured = capsys.readouterr()
|
|
1260
|
+
# Check for operation field
|
|
1261
|
+
assert "qa" in captured.out.lower() or "Operation" in captured.out, (
|
|
1262
|
+
"Output must include operation type"
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
def test_invoke_feedback_skill_includes_story_id_when_present(
|
|
1266
|
+
self, service, context_with_todos, capsys
|
|
1267
|
+
):
|
|
1268
|
+
"""
|
|
1269
|
+
AC#2: Output includes story_id when present.
|
|
1270
|
+
|
|
1271
|
+
Given: A context with story_id = "STORY-100"
|
|
1272
|
+
When: invoke_feedback_skill() is called
|
|
1273
|
+
Then: Output contains "STORY-100"
|
|
1274
|
+
"""
|
|
1275
|
+
# Arrange - from fixture
|
|
1276
|
+
|
|
1277
|
+
# Act
|
|
1278
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1279
|
+
|
|
1280
|
+
# Assert
|
|
1281
|
+
captured = capsys.readouterr()
|
|
1282
|
+
assert "STORY-100" in captured.out, (
|
|
1283
|
+
"Output must include story_id when present"
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
def test_invoke_feedback_skill_missing_story_id_shows_na(
|
|
1287
|
+
self, service, capsys
|
|
1288
|
+
):
|
|
1289
|
+
"""
|
|
1290
|
+
AC#2: Output shows "N/A" when story_id is absent.
|
|
1291
|
+
|
|
1292
|
+
Given: A context without story_id field
|
|
1293
|
+
When: invoke_feedback_skill() is called
|
|
1294
|
+
Then: Output contains "N/A" for story_id
|
|
1295
|
+
"""
|
|
1296
|
+
# Arrange
|
|
1297
|
+
context_no_story = {
|
|
1298
|
+
"operation_id": "devop-20260113-nostory",
|
|
1299
|
+
"operation": "dev",
|
|
1300
|
+
"status": "completed",
|
|
1301
|
+
"duration_ms": 100,
|
|
1302
|
+
"todos": [],
|
|
1303
|
+
"errors": [],
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
# Act
|
|
1307
|
+
service.invoke_feedback_skill(context_no_story)
|
|
1308
|
+
|
|
1309
|
+
# Assert
|
|
1310
|
+
captured = capsys.readouterr()
|
|
1311
|
+
assert "N/A" in captured.out or "unassigned" in captured.out, (
|
|
1312
|
+
"Output must show 'N/A' or 'unassigned' when story_id is absent"
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
def test_invoke_feedback_skill_includes_status_field(
|
|
1316
|
+
self, service, context_with_todos, capsys
|
|
1317
|
+
):
|
|
1318
|
+
"""
|
|
1319
|
+
AC#2: Output includes status field (completed/failed/error).
|
|
1320
|
+
|
|
1321
|
+
Given: A context with status = "failed"
|
|
1322
|
+
When: invoke_feedback_skill() is called
|
|
1323
|
+
Then: Output contains "failed" status
|
|
1324
|
+
"""
|
|
1325
|
+
# Arrange - from fixture (status = "failed")
|
|
1326
|
+
|
|
1327
|
+
# Act
|
|
1328
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1329
|
+
|
|
1330
|
+
# Assert
|
|
1331
|
+
captured = capsys.readouterr()
|
|
1332
|
+
assert "failed" in captured.out.lower(), (
|
|
1333
|
+
"Output must include status field"
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
def test_invoke_feedback_skill_todos_summary_calculated_correctly(
|
|
1337
|
+
self, service, context_with_todos, capsys
|
|
1338
|
+
):
|
|
1339
|
+
"""
|
|
1340
|
+
AC#2: Todos summary shows count and status breakdown.
|
|
1341
|
+
|
|
1342
|
+
Given: A context with 6 todos (3 completed, 1 in_progress, 2 pending)
|
|
1343
|
+
When: invoke_feedback_skill() is called
|
|
1344
|
+
Then: Output contains todos count and breakdown
|
|
1345
|
+
"""
|
|
1346
|
+
# Arrange - from fixture (6 todos: 3 completed, 1 in_progress, 2 pending)
|
|
1347
|
+
|
|
1348
|
+
# Act
|
|
1349
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1350
|
+
|
|
1351
|
+
# Assert
|
|
1352
|
+
captured = capsys.readouterr()
|
|
1353
|
+
output = captured.out
|
|
1354
|
+
|
|
1355
|
+
# Should contain count (6) and at least completed count (3)
|
|
1356
|
+
assert "6" in output, "Output must include total todos count"
|
|
1357
|
+
assert "3" in output, "Output must include completed todos count"
|
|
1358
|
+
|
|
1359
|
+
def test_invoke_feedback_skill_includes_errors_count(
|
|
1360
|
+
self, service, context_with_todos, capsys
|
|
1361
|
+
):
|
|
1362
|
+
"""
|
|
1363
|
+
AC#2: Output includes errors count.
|
|
1364
|
+
|
|
1365
|
+
Given: A context with 1 error
|
|
1366
|
+
When: invoke_feedback_skill() is called
|
|
1367
|
+
Then: Output contains error count
|
|
1368
|
+
"""
|
|
1369
|
+
# Arrange - from fixture (1 error)
|
|
1370
|
+
|
|
1371
|
+
# Act
|
|
1372
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1373
|
+
|
|
1374
|
+
# Assert
|
|
1375
|
+
captured = capsys.readouterr()
|
|
1376
|
+
# Output should mention errors (either count or the fact there are errors)
|
|
1377
|
+
assert "1" in captured.out or "error" in captured.out.lower(), (
|
|
1378
|
+
"Output must include errors count"
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
def test_invoke_feedback_skill_duration_formatted_with_ms(
|
|
1382
|
+
self, service, context_with_todos, capsys
|
|
1383
|
+
):
|
|
1384
|
+
"""
|
|
1385
|
+
AC#2: Duration is formatted in human-readable format (Xms).
|
|
1386
|
+
|
|
1387
|
+
Given: A context with duration_ms = 5000
|
|
1388
|
+
When: invoke_feedback_skill() is called
|
|
1389
|
+
Then: Output contains "5000ms" or similar format
|
|
1390
|
+
"""
|
|
1391
|
+
# Arrange - from fixture (duration_ms = 5000)
|
|
1392
|
+
|
|
1393
|
+
# Act
|
|
1394
|
+
service.invoke_feedback_skill(context_with_todos)
|
|
1395
|
+
|
|
1396
|
+
# Assert
|
|
1397
|
+
captured = capsys.readouterr()
|
|
1398
|
+
# Should contain duration value with ms suffix
|
|
1399
|
+
assert "5000" in captured.out, (
|
|
1400
|
+
"Output must include duration value"
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
class TestInvokeFeedbackSkillErrorHandling:
|
|
1405
|
+
"""
|
|
1406
|
+
Tests for AC#3: Error Handling and Graceful Degradation
|
|
1407
|
+
|
|
1408
|
+
The invoke_feedback_skill() method must handle exceptions gracefully,
|
|
1409
|
+
log errors, and return False without propagating exceptions.
|
|
1410
|
+
"""
|
|
1411
|
+
|
|
1412
|
+
@pytest.fixture
|
|
1413
|
+
def service(self):
|
|
1414
|
+
"""Fixture: HookInvocationService instance."""
|
|
1415
|
+
return HookInvocationService()
|
|
1416
|
+
|
|
1417
|
+
def test_invoke_feedback_skill_none_context_returns_false(self, service):
|
|
1418
|
+
"""
|
|
1419
|
+
AC#3: None context returns False.
|
|
1420
|
+
|
|
1421
|
+
Given: invoke_feedback_skill() is called with None
|
|
1422
|
+
When: The method attempts to process the context
|
|
1423
|
+
Then: Method returns False without raising exception
|
|
1424
|
+
"""
|
|
1425
|
+
# Arrange - None context
|
|
1426
|
+
|
|
1427
|
+
# Act
|
|
1428
|
+
result = service.invoke_feedback_skill(None)
|
|
1429
|
+
|
|
1430
|
+
# Assert
|
|
1431
|
+
assert result is False, (
|
|
1432
|
+
"Method must return False when context is None"
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
def test_invoke_feedback_skill_empty_dict_returns_false(self, service):
|
|
1436
|
+
"""
|
|
1437
|
+
AC#3: Empty dictionary context returns False.
|
|
1438
|
+
|
|
1439
|
+
Given: invoke_feedback_skill() is called with empty dict {}
|
|
1440
|
+
When: The method attempts to process the context
|
|
1441
|
+
Then: Method returns False (or handles gracefully with defaults)
|
|
1442
|
+
"""
|
|
1443
|
+
# Arrange
|
|
1444
|
+
empty_context = {}
|
|
1445
|
+
|
|
1446
|
+
# Act
|
|
1447
|
+
result = service.invoke_feedback_skill(empty_context)
|
|
1448
|
+
|
|
1449
|
+
# Assert - Either returns False OR handles gracefully with defaults
|
|
1450
|
+
# Per spec, empty context should be handled gracefully
|
|
1451
|
+
# Implementation may choose to output with "unknown" defaults
|
|
1452
|
+
assert result in (True, False), (
|
|
1453
|
+
"Method must return boolean (True with defaults or False)"
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
def test_invoke_feedback_skill_exception_logs_error(
|
|
1457
|
+
self, service, caplog
|
|
1458
|
+
):
|
|
1459
|
+
"""
|
|
1460
|
+
AC#3: Exception is logged using existing logger.
|
|
1461
|
+
|
|
1462
|
+
Given: A context that causes an exception during processing
|
|
1463
|
+
When: invoke_feedback_skill() is called
|
|
1464
|
+
Then: Error is logged via logger.error
|
|
1465
|
+
"""
|
|
1466
|
+
# Arrange - Context that should cause exception
|
|
1467
|
+
# Using a non-dict to trigger TypeError
|
|
1468
|
+
invalid_context = "not a dictionary"
|
|
1469
|
+
|
|
1470
|
+
# Act
|
|
1471
|
+
with caplog.at_level(logging.ERROR):
|
|
1472
|
+
result = service.invoke_feedback_skill(invalid_context)
|
|
1473
|
+
|
|
1474
|
+
# Assert
|
|
1475
|
+
assert result is False, "Method must return False on exception"
|
|
1476
|
+
# Note: Current stub doesn't log, so this will fail until implemented
|
|
1477
|
+
assert len(caplog.records) > 0, (
|
|
1478
|
+
"Exception must be logged via logger.error"
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
def test_invoke_feedback_skill_no_exception_propagates(self, service):
|
|
1482
|
+
"""
|
|
1483
|
+
AC#3: No exception propagates to caller.
|
|
1484
|
+
|
|
1485
|
+
Given: A context that causes an exception
|
|
1486
|
+
When: invoke_feedback_skill() is called
|
|
1487
|
+
Then: No exception is raised (method catches all)
|
|
1488
|
+
"""
|
|
1489
|
+
# Arrange - Various invalid contexts
|
|
1490
|
+
invalid_contexts = [
|
|
1491
|
+
None,
|
|
1492
|
+
"string",
|
|
1493
|
+
123,
|
|
1494
|
+
["list"],
|
|
1495
|
+
{"nested": {"deep": object()}}, # Non-serializable
|
|
1496
|
+
]
|
|
1497
|
+
|
|
1498
|
+
# Act & Assert - None should raise
|
|
1499
|
+
for invalid_ctx in invalid_contexts:
|
|
1500
|
+
try:
|
|
1501
|
+
result = service.invoke_feedback_skill(invalid_ctx)
|
|
1502
|
+
# If we get here, no exception was raised - good!
|
|
1503
|
+
assert result in (True, False), (
|
|
1504
|
+
f"Method must return boolean for context: {type(invalid_ctx)}"
|
|
1505
|
+
)
|
|
1506
|
+
except Exception as e:
|
|
1507
|
+
pytest.fail(
|
|
1508
|
+
f"Exception propagated to caller for context "
|
|
1509
|
+
f"{type(invalid_ctx)}: {e}"
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
def test_invoke_feedback_skill_missing_keys_handled_gracefully(
|
|
1513
|
+
self, service, capsys
|
|
1514
|
+
):
|
|
1515
|
+
"""
|
|
1516
|
+
AC#3: Missing required keys are handled gracefully.
|
|
1517
|
+
|
|
1518
|
+
Given: A context missing operation_id and other keys
|
|
1519
|
+
When: invoke_feedback_skill() is called
|
|
1520
|
+
Then: Method handles gracefully (uses defaults or returns False)
|
|
1521
|
+
"""
|
|
1522
|
+
# Arrange - Context with only partial data
|
|
1523
|
+
partial_context = {
|
|
1524
|
+
"operation": "dev",
|
|
1525
|
+
"status": "completed",
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
# Act
|
|
1529
|
+
result = service.invoke_feedback_skill(partial_context)
|
|
1530
|
+
|
|
1531
|
+
# Assert - Should handle gracefully
|
|
1532
|
+
# Either returns True with defaults or False for incomplete data
|
|
1533
|
+
assert result in (True, False), (
|
|
1534
|
+
"Method must return boolean for partial context"
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
def test_invoke_feedback_skill_logger_debug_with_stack_trace(
|
|
1538
|
+
self, service, caplog
|
|
1539
|
+
):
|
|
1540
|
+
"""
|
|
1541
|
+
AC#3: Logger.debug called with stack trace on exception.
|
|
1542
|
+
|
|
1543
|
+
Given: A context that causes an exception
|
|
1544
|
+
When: invoke_feedback_skill() is called
|
|
1545
|
+
Then: Stack trace is logged at DEBUG level
|
|
1546
|
+
"""
|
|
1547
|
+
# Arrange
|
|
1548
|
+
invalid_context = None
|
|
1549
|
+
|
|
1550
|
+
# Act
|
|
1551
|
+
with caplog.at_level(logging.DEBUG):
|
|
1552
|
+
service.invoke_feedback_skill(invalid_context)
|
|
1553
|
+
|
|
1554
|
+
# Assert - Check for debug log with stack trace
|
|
1555
|
+
debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG]
|
|
1556
|
+
# Note: Current stub doesn't log debug, so this will fail
|
|
1557
|
+
assert any("Traceback" in r.message or "Error" in r.message
|
|
1558
|
+
for r in debug_records), (
|
|
1559
|
+
"Stack trace must be logged at DEBUG level"
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
class TestInvokeFeedbackSkillOutputParsability:
|
|
1564
|
+
"""
|
|
1565
|
+
Tests for AC#4: Output Format Parsability
|
|
1566
|
+
|
|
1567
|
+
The output must have clear delimiters, consistent format,
|
|
1568
|
+
proper escaping, and format version indicator.
|
|
1569
|
+
"""
|
|
1570
|
+
|
|
1571
|
+
@pytest.fixture
|
|
1572
|
+
def service(self):
|
|
1573
|
+
"""Fixture: HookInvocationService instance."""
|
|
1574
|
+
return HookInvocationService()
|
|
1575
|
+
|
|
1576
|
+
@pytest.fixture
|
|
1577
|
+
def standard_context(self):
|
|
1578
|
+
"""Fixture: Standard context for parsability tests."""
|
|
1579
|
+
return {
|
|
1580
|
+
"operation_id": "devop-20260113-parse",
|
|
1581
|
+
"operation": "release",
|
|
1582
|
+
"story_id": "STORY-200",
|
|
1583
|
+
"status": "completed",
|
|
1584
|
+
"duration_ms": 3000,
|
|
1585
|
+
"todos": [{"id": "1", "status": "completed", "content": "Done"}],
|
|
1586
|
+
"errors": [],
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
def test_invoke_feedback_skill_output_has_delimiters(
|
|
1590
|
+
self, service, standard_context, capsys
|
|
1591
|
+
):
|
|
1592
|
+
"""
|
|
1593
|
+
AC#4: Output has clear start/end delimiters.
|
|
1594
|
+
|
|
1595
|
+
Given: A valid context
|
|
1596
|
+
When: invoke_feedback_skill() is called
|
|
1597
|
+
Then: Output has delimiter at start and end
|
|
1598
|
+
"""
|
|
1599
|
+
# Arrange - from fixture
|
|
1600
|
+
|
|
1601
|
+
# Act
|
|
1602
|
+
service.invoke_feedback_skill(standard_context)
|
|
1603
|
+
|
|
1604
|
+
# Assert
|
|
1605
|
+
captured = capsys.readouterr()
|
|
1606
|
+
output = captured.out
|
|
1607
|
+
|
|
1608
|
+
delimiter = "=" * 62
|
|
1609
|
+
delimiter_count = output.count(delimiter)
|
|
1610
|
+
assert delimiter_count >= 2, (
|
|
1611
|
+
f"Output must have at least 2 delimiter lines, found {delimiter_count}"
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
def test_invoke_feedback_skill_consistent_key_value_format(
|
|
1615
|
+
self, service, standard_context, capsys
|
|
1616
|
+
):
|
|
1617
|
+
"""
|
|
1618
|
+
AC#4: Context data uses consistent key-value format.
|
|
1619
|
+
|
|
1620
|
+
Given: A valid context
|
|
1621
|
+
When: invoke_feedback_skill() is called
|
|
1622
|
+
Then: All context fields use "Key: Value" format
|
|
1623
|
+
"""
|
|
1624
|
+
# Arrange - from fixture
|
|
1625
|
+
|
|
1626
|
+
# Act
|
|
1627
|
+
service.invoke_feedback_skill(standard_context)
|
|
1628
|
+
|
|
1629
|
+
# Assert
|
|
1630
|
+
captured = capsys.readouterr()
|
|
1631
|
+
output = captured.out
|
|
1632
|
+
|
|
1633
|
+
# Check for key-value patterns (e.g., "Operation: dev")
|
|
1634
|
+
import re
|
|
1635
|
+
key_value_pattern = re.compile(r"^\s*[A-Za-z][A-Za-z\s]*:\s*.+$", re.MULTILINE)
|
|
1636
|
+
matches = key_value_pattern.findall(output)
|
|
1637
|
+
|
|
1638
|
+
assert len(matches) >= 3, (
|
|
1639
|
+
f"Output must have at least 3 key-value lines, found {len(matches)}"
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
def test_invoke_feedback_skill_special_chars_escaped(
|
|
1643
|
+
self, service, capsys
|
|
1644
|
+
):
|
|
1645
|
+
"""
|
|
1646
|
+
AC#4: Special characters are properly escaped.
|
|
1647
|
+
|
|
1648
|
+
Given: A context with special characters (newlines, quotes, unicode)
|
|
1649
|
+
When: invoke_feedback_skill() is called
|
|
1650
|
+
Then: Output properly handles special characters without breaking format
|
|
1651
|
+
"""
|
|
1652
|
+
# Arrange - Context with special characters
|
|
1653
|
+
context_with_specials = {
|
|
1654
|
+
"operation_id": 'devop-with-"quotes"',
|
|
1655
|
+
"operation": "dev",
|
|
1656
|
+
"story_id": "STORY-256",
|
|
1657
|
+
"status": "completed",
|
|
1658
|
+
"duration_ms": 100,
|
|
1659
|
+
"todos": [
|
|
1660
|
+
{"id": "1", "status": "completed", "content": "Task with\nnewline"},
|
|
1661
|
+
],
|
|
1662
|
+
"errors": [{"message": "Error with 'quotes' and \"double quotes\""}],
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
# Act
|
|
1666
|
+
result = service.invoke_feedback_skill(context_with_specials)
|
|
1667
|
+
|
|
1668
|
+
# Assert
|
|
1669
|
+
captured = capsys.readouterr()
|
|
1670
|
+
output = captured.out
|
|
1671
|
+
|
|
1672
|
+
# Should complete without error
|
|
1673
|
+
assert result is True, "Method should handle special characters"
|
|
1674
|
+
# Output should not have broken formatting
|
|
1675
|
+
assert "===" in output, "Delimiters should still be present"
|
|
1676
|
+
|
|
1677
|
+
def test_invoke_feedback_skill_unicode_characters_handled(
|
|
1678
|
+
self, service, capsys
|
|
1679
|
+
):
|
|
1680
|
+
"""
|
|
1681
|
+
AC#4: Unicode characters are properly handled.
|
|
1682
|
+
|
|
1683
|
+
Given: A context with Unicode characters (emojis, international chars)
|
|
1684
|
+
When: invoke_feedback_skill() is called
|
|
1685
|
+
Then: Output includes Unicode without encoding errors
|
|
1686
|
+
"""
|
|
1687
|
+
# Arrange - Context with Unicode
|
|
1688
|
+
context_with_unicode = {
|
|
1689
|
+
"operation_id": "devop-unicode-test",
|
|
1690
|
+
"operation": "dev",
|
|
1691
|
+
"story_id": "STORY-256",
|
|
1692
|
+
"status": "completed",
|
|
1693
|
+
"duration_ms": 100,
|
|
1694
|
+
"todos": [
|
|
1695
|
+
{"id": "1", "status": "completed", "content": "Task with emoji"},
|
|
1696
|
+
],
|
|
1697
|
+
"errors": [],
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
# Act - Should not raise UnicodeEncodeError
|
|
1701
|
+
try:
|
|
1702
|
+
result = service.invoke_feedback_skill(context_with_unicode)
|
|
1703
|
+
except UnicodeEncodeError:
|
|
1704
|
+
pytest.fail("Method must handle Unicode without raising UnicodeEncodeError")
|
|
1705
|
+
|
|
1706
|
+
# Assert
|
|
1707
|
+
assert result is True, "Method should handle Unicode characters"
|
|
1708
|
+
|
|
1709
|
+
def test_invoke_feedback_skill_format_version_present(
|
|
1710
|
+
self, service, standard_context, capsys
|
|
1711
|
+
):
|
|
1712
|
+
"""
|
|
1713
|
+
AC#4: Format version indicator is present.
|
|
1714
|
+
|
|
1715
|
+
Given: A valid context
|
|
1716
|
+
When: invoke_feedback_skill() is called
|
|
1717
|
+
Then: Output contains format identifier (e.g., "FEEDBACK HOOK TRIGGERED")
|
|
1718
|
+
"""
|
|
1719
|
+
# Arrange - from fixture
|
|
1720
|
+
|
|
1721
|
+
# Act
|
|
1722
|
+
service.invoke_feedback_skill(standard_context)
|
|
1723
|
+
|
|
1724
|
+
# Assert
|
|
1725
|
+
captured = capsys.readouterr()
|
|
1726
|
+
output = captured.out
|
|
1727
|
+
|
|
1728
|
+
# Per spec: "FEEDBACK HOOK TRIGGERED" is the format indicator
|
|
1729
|
+
assert "FEEDBACK HOOK TRIGGERED" in output or "HOOK" in output, (
|
|
1730
|
+
"Output must contain format version/type indicator"
|
|
1731
|
+
)
|
|
1732
|
+
|
|
1733
|
+
def test_invoke_feedback_skill_returns_true_on_success(
|
|
1734
|
+
self, service, standard_context
|
|
1735
|
+
):
|
|
1736
|
+
"""
|
|
1737
|
+
AC#3/AC#4: Method returns True on successful output generation.
|
|
1738
|
+
|
|
1739
|
+
Given: A valid context with all required fields
|
|
1740
|
+
When: invoke_feedback_skill() is called
|
|
1741
|
+
Then: Method returns True indicating success
|
|
1742
|
+
"""
|
|
1743
|
+
# Arrange - from fixture
|
|
1744
|
+
|
|
1745
|
+
# Act
|
|
1746
|
+
result = service.invoke_feedback_skill(standard_context)
|
|
1747
|
+
|
|
1748
|
+
# Assert
|
|
1749
|
+
assert result is True, (
|
|
1750
|
+
"Method must return True when output is successfully generated"
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
class TestInvokeFeedbackSkillMinimalContext:
|
|
1755
|
+
"""
|
|
1756
|
+
Tests for edge cases with minimal/partial context data.
|
|
1757
|
+
"""
|
|
1758
|
+
|
|
1759
|
+
@pytest.fixture
|
|
1760
|
+
def service(self):
|
|
1761
|
+
"""Fixture: HookInvocationService instance."""
|
|
1762
|
+
return HookInvocationService()
|
|
1763
|
+
|
|
1764
|
+
def test_invoke_feedback_skill_minimal_context_outputs_defaults(
|
|
1765
|
+
self, service, capsys
|
|
1766
|
+
):
|
|
1767
|
+
"""
|
|
1768
|
+
AC#2: Minimal context (operation_id only) outputs with defaults.
|
|
1769
|
+
|
|
1770
|
+
Given: A context with only operation_id
|
|
1771
|
+
When: invoke_feedback_skill() is called
|
|
1772
|
+
Then: Output includes operation_id and defaults for missing fields
|
|
1773
|
+
"""
|
|
1774
|
+
# Arrange
|
|
1775
|
+
minimal_context = {
|
|
1776
|
+
"operation_id": "devop-minimal-test",
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
# Act
|
|
1780
|
+
result = service.invoke_feedback_skill(minimal_context)
|
|
1781
|
+
|
|
1782
|
+
# Assert
|
|
1783
|
+
captured = capsys.readouterr()
|
|
1784
|
+
output = captured.out
|
|
1785
|
+
|
|
1786
|
+
# Should include the operation_id we provided
|
|
1787
|
+
assert "devop-minimal-test" in output, (
|
|
1788
|
+
"Output must include provided operation_id"
|
|
1789
|
+
)
|
|
1790
|
+
# Should handle missing fields gracefully
|
|
1791
|
+
assert result is True, (
|
|
1792
|
+
"Method should succeed with minimal context using defaults"
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
def test_invoke_feedback_skill_empty_todos_list(
|
|
1796
|
+
self, service, capsys
|
|
1797
|
+
):
|
|
1798
|
+
"""
|
|
1799
|
+
AC#2: Empty todos list is handled correctly.
|
|
1800
|
+
|
|
1801
|
+
Given: A context with empty todos list
|
|
1802
|
+
When: invoke_feedback_skill() is called
|
|
1803
|
+
Then: Output shows 0 items for todos
|
|
1804
|
+
"""
|
|
1805
|
+
# Arrange
|
|
1806
|
+
context_empty_todos = {
|
|
1807
|
+
"operation_id": "devop-empty-todos",
|
|
1808
|
+
"operation": "dev",
|
|
1809
|
+
"story_id": "STORY-256",
|
|
1810
|
+
"status": "completed",
|
|
1811
|
+
"duration_ms": 100,
|
|
1812
|
+
"todos": [],
|
|
1813
|
+
"errors": [],
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
# Act
|
|
1817
|
+
service.invoke_feedback_skill(context_empty_todos)
|
|
1818
|
+
|
|
1819
|
+
# Assert
|
|
1820
|
+
captured = capsys.readouterr()
|
|
1821
|
+
# Should show 0 items or empty indicator
|
|
1822
|
+
assert "0" in captured.out or "empty" in captured.out.lower(), (
|
|
1823
|
+
"Output must indicate zero todos"
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
def test_invoke_feedback_skill_zero_duration(
|
|
1827
|
+
self, service, capsys
|
|
1828
|
+
):
|
|
1829
|
+
"""
|
|
1830
|
+
AC#2: Zero duration is formatted correctly.
|
|
1831
|
+
|
|
1832
|
+
Given: A context with duration_ms = 0
|
|
1833
|
+
When: invoke_feedback_skill() is called
|
|
1834
|
+
Then: Output shows "0ms" or similar
|
|
1835
|
+
"""
|
|
1836
|
+
# Arrange
|
|
1837
|
+
context_zero_duration = {
|
|
1838
|
+
"operation_id": "devop-zero-duration",
|
|
1839
|
+
"operation": "dev",
|
|
1840
|
+
"story_id": "STORY-256",
|
|
1841
|
+
"status": "completed",
|
|
1842
|
+
"duration_ms": 0,
|
|
1843
|
+
"todos": [],
|
|
1844
|
+
"errors": [],
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
# Act
|
|
1848
|
+
service.invoke_feedback_skill(context_zero_duration)
|
|
1849
|
+
|
|
1850
|
+
# Assert
|
|
1851
|
+
captured = capsys.readouterr()
|
|
1852
|
+
# Should contain 0 for duration
|
|
1853
|
+
assert "0" in captured.out, (
|
|
1854
|
+
"Output must show zero duration"
|
|
1855
|
+
)
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
# ============================================================================
|
|
1859
|
+
# STORY-256: Test Summary
|
|
1860
|
+
#
|
|
1861
|
+
# Total test count: 24 tests across 5 test classes
|
|
1862
|
+
#
|
|
1863
|
+
# TestInvokeFeedbackSkillStructuredOutput (AC#1): 6 tests
|
|
1864
|
+
# - test_invoke_feedback_skill_complete_context_outputs_all_fields
|
|
1865
|
+
# - test_invoke_feedback_skill_outputs_section_header_with_delimiter
|
|
1866
|
+
# - test_invoke_feedback_skill_outputs_skill_name
|
|
1867
|
+
# - test_invoke_feedback_skill_outputs_invocation_instructions
|
|
1868
|
+
# - test_invoke_feedback_skill_uses_consistent_indentation
|
|
1869
|
+
#
|
|
1870
|
+
# TestInvokeFeedbackSkillContextDataInclusion (AC#2): 8 tests
|
|
1871
|
+
# - test_invoke_feedback_skill_includes_operation_id
|
|
1872
|
+
# - test_invoke_feedback_skill_includes_operation_type
|
|
1873
|
+
# - test_invoke_feedback_skill_includes_story_id_when_present
|
|
1874
|
+
# - test_invoke_feedback_skill_missing_story_id_shows_na
|
|
1875
|
+
# - test_invoke_feedback_skill_includes_status_field
|
|
1876
|
+
# - test_invoke_feedback_skill_todos_summary_calculated_correctly
|
|
1877
|
+
# - test_invoke_feedback_skill_includes_errors_count
|
|
1878
|
+
# - test_invoke_feedback_skill_duration_formatted_with_ms
|
|
1879
|
+
#
|
|
1880
|
+
# TestInvokeFeedbackSkillErrorHandling (AC#3): 6 tests
|
|
1881
|
+
# - test_invoke_feedback_skill_none_context_returns_false
|
|
1882
|
+
# - test_invoke_feedback_skill_empty_dict_returns_false
|
|
1883
|
+
# - test_invoke_feedback_skill_exception_logs_error
|
|
1884
|
+
# - test_invoke_feedback_skill_no_exception_propagates
|
|
1885
|
+
# - test_invoke_feedback_skill_missing_keys_handled_gracefully
|
|
1886
|
+
# - test_invoke_feedback_skill_logger_debug_with_stack_trace
|
|
1887
|
+
#
|
|
1888
|
+
# TestInvokeFeedbackSkillOutputParsability (AC#4): 6 tests
|
|
1889
|
+
# - test_invoke_feedback_skill_output_has_delimiters
|
|
1890
|
+
# - test_invoke_feedback_skill_consistent_key_value_format
|
|
1891
|
+
# - test_invoke_feedback_skill_special_chars_escaped
|
|
1892
|
+
# - test_invoke_feedback_skill_unicode_characters_handled
|
|
1893
|
+
# - test_invoke_feedback_skill_format_version_present
|
|
1894
|
+
# - test_invoke_feedback_skill_returns_true_on_success
|
|
1895
|
+
#
|
|
1896
|
+
# TestInvokeFeedbackSkillMinimalContext (Edge Cases): 3 tests
|
|
1897
|
+
# - test_invoke_feedback_skill_minimal_context_outputs_defaults
|
|
1898
|
+
# - test_invoke_feedback_skill_empty_todos_list
|
|
1899
|
+
# - test_invoke_feedback_skill_zero_duration
|
|
1900
|
+
#
|
|
1901
|
+
# TDD Status: RED (All tests expected to FAIL against current stub)
|
|
1902
|
+
# ============================================================================
|