mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: add C++ language support and hook tests (#539)
- agents: cpp-build-resolver, cpp-reviewer - commands: cpp-build, cpp-review, cpp-test - rules: cpp/ (coding-style, hooks, patterns, security, testing) - tests: 9 new hook test files with comprehensive coverage Cherry-picked from PR #436.
This commit is contained in:
90
agents/cpp-build-resolver.md
Normal file
90
agents/cpp-build-resolver.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: cpp-build-resolver
|
||||||
|
description: C++ build, CMake, and compilation error resolution specialist. Fixes build errors, linker issues, and template errors with minimal changes. Use when C++ builds fail.
|
||||||
|
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
# C++ Build Error Resolver
|
||||||
|
|
||||||
|
You are an expert C++ build error resolution specialist. Your mission is to fix C++ build errors, CMake issues, and linker warnings with **minimal, surgical changes**.
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. Diagnose C++ compilation errors
|
||||||
|
2. Fix CMake configuration issues
|
||||||
|
3. Resolve linker errors (undefined references, multiple definitions)
|
||||||
|
4. Handle template instantiation errors
|
||||||
|
5. Fix include and dependency problems
|
||||||
|
|
||||||
|
## Diagnostic Commands
|
||||||
|
|
||||||
|
Run these in order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build 2>&1 | head -100
|
||||||
|
cmake -B build -S . 2>&1 | tail -30
|
||||||
|
clang-tidy src/*.cpp -- -std=c++17 2>/dev/null || echo "clang-tidy not available"
|
||||||
|
cppcheck --enable=all src/ 2>/dev/null || echo "cppcheck not available"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolution Workflow
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. cmake --build build -> Parse error message
|
||||||
|
2. Read affected file -> Understand context
|
||||||
|
3. Apply minimal fix -> Only what's needed
|
||||||
|
4. cmake --build build -> Verify fix
|
||||||
|
5. ctest --test-dir build -> Ensure nothing broke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Fix Patterns
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `undefined reference to X` | Missing implementation or library | Add source file or link library |
|
||||||
|
| `no matching function for call` | Wrong argument types | Fix types or add overload |
|
||||||
|
| `expected ';'` | Syntax error | Fix syntax |
|
||||||
|
| `use of undeclared identifier` | Missing include or typo | Add `#include` or fix name |
|
||||||
|
| `multiple definition of` | Duplicate symbol | Use `inline`, move to .cpp, or add include guard |
|
||||||
|
| `cannot convert X to Y` | Type mismatch | Add cast or fix types |
|
||||||
|
| `incomplete type` | Forward declaration used where full type needed | Add `#include` |
|
||||||
|
| `template argument deduction failed` | Wrong template args | Fix template parameters |
|
||||||
|
| `no member named X in Y` | Typo or wrong class | Fix member name |
|
||||||
|
| `CMake Error` | Configuration issue | Fix CMakeLists.txt |
|
||||||
|
|
||||||
|
## CMake Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -S . -DCMAKE_VERBOSE_MAKEFILE=ON
|
||||||
|
cmake --build build --verbose
|
||||||
|
cmake --build build --clean-first
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
- **Surgical fixes only** -- don't refactor, just fix the error
|
||||||
|
- **Never** suppress warnings with `#pragma` without approval
|
||||||
|
- **Never** change function signatures unless necessary
|
||||||
|
- Fix root cause over suppressing symptoms
|
||||||
|
- One fix at a time, verify after each
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop and report if:
|
||||||
|
- Same error persists after 3 fix attempts
|
||||||
|
- Fix introduces more errors than it resolves
|
||||||
|
- Error requires architectural changes beyond scope
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```text
|
||||||
|
[FIXED] src/handler/user.cpp:42
|
||||||
|
Error: undefined reference to `UserService::create`
|
||||||
|
Fix: Added missing method implementation in user_service.cpp
|
||||||
|
Remaining errors: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list`
|
||||||
|
|
||||||
|
For detailed C++ patterns and code examples, see `skill: cpp-coding-standards`.
|
||||||
72
agents/cpp-reviewer.md
Normal file
72
agents/cpp-reviewer.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: cpp-reviewer
|
||||||
|
description: Expert C++ code reviewer specializing in memory safety, modern C++ idioms, concurrency, and performance. Use for all C++ code changes. MUST BE USED for C++ projects.
|
||||||
|
tools: ["Read", "Grep", "Glob", "Bash"]
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a senior C++ code reviewer ensuring high standards of modern C++ and best practices.
|
||||||
|
|
||||||
|
When invoked:
|
||||||
|
1. Run `git diff -- '*.cpp' '*.hpp' '*.cc' '*.hh' '*.cxx' '*.h'` to see recent C++ file changes
|
||||||
|
2. Run `clang-tidy` and `cppcheck` if available
|
||||||
|
3. Focus on modified C++ files
|
||||||
|
4. Begin review immediately
|
||||||
|
|
||||||
|
## Review Priorities
|
||||||
|
|
||||||
|
### CRITICAL -- Memory Safety
|
||||||
|
- **Raw new/delete**: Use `std::unique_ptr` or `std::shared_ptr`
|
||||||
|
- **Buffer overflows**: C-style arrays, `strcpy`, `sprintf` without bounds
|
||||||
|
- **Use-after-free**: Dangling pointers, invalidated iterators
|
||||||
|
- **Uninitialized variables**: Reading before assignment
|
||||||
|
- **Memory leaks**: Missing RAII, resources not tied to object lifetime
|
||||||
|
- **Null dereference**: Pointer access without null check
|
||||||
|
|
||||||
|
### CRITICAL -- Security
|
||||||
|
- **Command injection**: Unvalidated input in `system()` or `popen()`
|
||||||
|
- **Format string attacks**: User input in `printf` format string
|
||||||
|
- **Integer overflow**: Unchecked arithmetic on untrusted input
|
||||||
|
- **Hardcoded secrets**: API keys, passwords in source
|
||||||
|
- **Unsafe casts**: `reinterpret_cast` without justification
|
||||||
|
|
||||||
|
### HIGH -- Concurrency
|
||||||
|
- **Data races**: Shared mutable state without synchronization
|
||||||
|
- **Deadlocks**: Multiple mutexes locked in inconsistent order
|
||||||
|
- **Missing lock guards**: Manual `lock()`/`unlock()` instead of `std::lock_guard`
|
||||||
|
- **Detached threads**: `std::thread` without `join()` or `detach()`
|
||||||
|
|
||||||
|
### HIGH -- Code Quality
|
||||||
|
- **No RAII**: Manual resource management
|
||||||
|
- **Rule of Five violations**: Incomplete special member functions
|
||||||
|
- **Large functions**: Over 50 lines
|
||||||
|
- **Deep nesting**: More than 4 levels
|
||||||
|
- **C-style code**: `malloc`, C arrays, `typedef` instead of `using`
|
||||||
|
|
||||||
|
### MEDIUM -- Performance
|
||||||
|
- **Unnecessary copies**: Pass large objects by value instead of `const&`
|
||||||
|
- **Missing move semantics**: Not using `std::move` for sink parameters
|
||||||
|
- **String concatenation in loops**: Use `std::ostringstream` or `reserve()`
|
||||||
|
- **Missing `reserve()`**: Known-size vector without pre-allocation
|
||||||
|
|
||||||
|
### MEDIUM -- Best Practices
|
||||||
|
- **`const` correctness**: Missing `const` on methods, parameters, references
|
||||||
|
- **`auto` overuse/underuse**: Balance readability with type deduction
|
||||||
|
- **Include hygiene**: Missing include guards, unnecessary includes
|
||||||
|
- **Namespace pollution**: `using namespace std;` in headers
|
||||||
|
|
||||||
|
## Diagnostic Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clang-tidy --checks='*,-llvmlibc-*' src/*.cpp -- -std=c++17
|
||||||
|
cppcheck --enable=all --suppress=missingIncludeSystem src/
|
||||||
|
cmake --build build 2>&1 | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Approval Criteria
|
||||||
|
|
||||||
|
- **Approve**: No CRITICAL or HIGH issues
|
||||||
|
- **Warning**: MEDIUM issues only
|
||||||
|
- **Block**: CRITICAL or HIGH issues found
|
||||||
|
|
||||||
|
For detailed C++ coding standards and anti-patterns, see `skill: cpp-coding-standards`.
|
||||||
173
commands/cpp-build.md
Normal file
173
commands/cpp-build.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
description: Fix C++ build errors, CMake issues, and linker problems incrementally. Invokes the cpp-build-resolver agent for minimal, surgical fixes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# C++ Build and Fix
|
||||||
|
|
||||||
|
This command invokes the **cpp-build-resolver** agent to incrementally fix C++ build errors with minimal changes.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Run Diagnostics**: Execute `cmake --build`, `clang-tidy`, `cppcheck`
|
||||||
|
2. **Parse Errors**: Group by file and sort by severity
|
||||||
|
3. **Fix Incrementally**: One error at a time
|
||||||
|
4. **Verify Each Fix**: Re-run build after each change
|
||||||
|
5. **Report Summary**: Show what was fixed and what remains
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/cpp-build` when:
|
||||||
|
- `cmake --build build` fails with errors
|
||||||
|
- Linker errors (undefined references, multiple definitions)
|
||||||
|
- Template instantiation failures
|
||||||
|
- Include/dependency issues
|
||||||
|
- After pulling changes that break the build
|
||||||
|
|
||||||
|
## Diagnostic Commands Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CMake configure
|
||||||
|
cmake -B build -S .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
cmake --build build 2>&1 | head -100
|
||||||
|
|
||||||
|
# Static analysis (if available)
|
||||||
|
clang-tidy src/*.cpp -- -std=c++17
|
||||||
|
cppcheck --enable=all src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
```text
|
||||||
|
User: /cpp-build
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# C++ Build Resolution
|
||||||
|
|
||||||
|
## Initial Diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build
|
||||||
|
src/service/user.cpp:25:15: error: use of undeclared identifier 'UserRepository'
|
||||||
|
src/handler/api.cpp:42:9: error: no matching function for call to 'process'
|
||||||
|
src/handler/api.cpp:58:1: error: non-void function does not return a value
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors found: 3
|
||||||
|
|
||||||
|
## Fix 1: Undeclared Identifier
|
||||||
|
|
||||||
|
File: src/service/user.cpp:25
|
||||||
|
Error: use of undeclared identifier 'UserRepository'
|
||||||
|
Cause: Missing include
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Added include
|
||||||
|
#include "repository/user_repository.hpp"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build
|
||||||
|
# 2 errors remaining
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix 2: No Matching Function
|
||||||
|
|
||||||
|
File: src/handler/api.cpp:42
|
||||||
|
Error: no matching function for call to 'process'
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Changed
|
||||||
|
process(params.get("count"));
|
||||||
|
// To
|
||||||
|
process(std::stoi(params.get("count")));
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build
|
||||||
|
# 1 error remaining
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix 3: Missing Return
|
||||||
|
|
||||||
|
File: src/handler/api.cpp:58
|
||||||
|
Error: non-void function does not return a value
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::optional<User> getUser(const std::string& id) {
|
||||||
|
if (id.empty()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
auto user = findUser(id);
|
||||||
|
// Added missing return
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build
|
||||||
|
# Build successful!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ctest --test-dir build --output-on-failure
|
||||||
|
Test project build
|
||||||
|
1/5 Test #1: unit_tests ........ Passed 0.02 sec
|
||||||
|
2/5 Test #2: integration_tests Passed 0.15 sec
|
||||||
|
All tests passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Build errors fixed | 3 |
|
||||||
|
| Linker errors fixed | 0 |
|
||||||
|
| Files modified | 2 |
|
||||||
|
| Remaining issues | 0 |
|
||||||
|
|
||||||
|
Build Status: ✅ SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors Fixed
|
||||||
|
|
||||||
|
| Error | Typical Fix |
|
||||||
|
|-------|-------------|
|
||||||
|
| `undeclared identifier` | Add `#include` or fix typo |
|
||||||
|
| `no matching function` | Fix argument types or add overload |
|
||||||
|
| `undefined reference` | Link library or add implementation |
|
||||||
|
| `multiple definition` | Use `inline` or move to .cpp |
|
||||||
|
| `incomplete type` | Replace forward decl with `#include` |
|
||||||
|
| `no member named X` | Fix member name or include |
|
||||||
|
| `cannot convert X to Y` | Add appropriate cast |
|
||||||
|
| `CMake Error` | Fix CMakeLists.txt configuration |
|
||||||
|
|
||||||
|
## Fix Strategy
|
||||||
|
|
||||||
|
1. **Compilation errors first** - Code must compile
|
||||||
|
2. **Linker errors second** - Resolve undefined references
|
||||||
|
3. **Warnings third** - Fix with `-Wall -Wextra`
|
||||||
|
4. **One fix at a time** - Verify each change
|
||||||
|
5. **Minimal changes** - Don't refactor, just fix
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
The agent will stop and report if:
|
||||||
|
- Same error persists after 3 attempts
|
||||||
|
- Fix introduces more errors
|
||||||
|
- Requires architectural changes
|
||||||
|
- Missing external dependencies
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
- `/cpp-test` - Run tests after build succeeds
|
||||||
|
- `/cpp-review` - Review code quality
|
||||||
|
- `/verify` - Full verification loop
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Agent: `agents/cpp-build-resolver.md`
|
||||||
|
- Skill: `skills/cpp-coding-standards/`
|
||||||
132
commands/cpp-review.md
Normal file
132
commands/cpp-review.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
description: Comprehensive C++ code review for memory safety, modern C++ idioms, concurrency, and security. Invokes the cpp-reviewer agent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# C++ Code Review
|
||||||
|
|
||||||
|
This command invokes the **cpp-reviewer** agent for comprehensive C++-specific code review.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Identify C++ Changes**: Find modified `.cpp`, `.hpp`, `.cc`, `.h` files via `git diff`
|
||||||
|
2. **Run Static Analysis**: Execute `clang-tidy` and `cppcheck`
|
||||||
|
3. **Memory Safety Scan**: Check for raw new/delete, buffer overflows, use-after-free
|
||||||
|
4. **Concurrency Review**: Analyze thread safety, mutex usage, data races
|
||||||
|
5. **Modern C++ Check**: Verify code follows C++17/20 conventions and best practices
|
||||||
|
6. **Generate Report**: Categorize issues by severity
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/cpp-review` when:
|
||||||
|
- After writing or modifying C++ code
|
||||||
|
- Before committing C++ changes
|
||||||
|
- Reviewing pull requests with C++ code
|
||||||
|
- Onboarding to a new C++ codebase
|
||||||
|
- Checking for memory safety issues
|
||||||
|
|
||||||
|
## Review Categories
|
||||||
|
|
||||||
|
### CRITICAL (Must Fix)
|
||||||
|
- Raw `new`/`delete` without RAII
|
||||||
|
- Buffer overflows and use-after-free
|
||||||
|
- Data races without synchronization
|
||||||
|
- Command injection via `system()`
|
||||||
|
- Uninitialized variable reads
|
||||||
|
- Null pointer dereferences
|
||||||
|
|
||||||
|
### HIGH (Should Fix)
|
||||||
|
- Rule of Five violations
|
||||||
|
- Missing `std::lock_guard` / `std::scoped_lock`
|
||||||
|
- Detached threads without proper lifetime management
|
||||||
|
- C-style casts instead of `static_cast`/`dynamic_cast`
|
||||||
|
- Missing `const` correctness
|
||||||
|
|
||||||
|
### MEDIUM (Consider)
|
||||||
|
- Unnecessary copies (pass by value instead of `const&`)
|
||||||
|
- Missing `reserve()` on known-size containers
|
||||||
|
- `using namespace std;` in headers
|
||||||
|
- Missing `[[nodiscard]]` on important return values
|
||||||
|
- Overly complex template metaprogramming
|
||||||
|
|
||||||
|
## Automated Checks Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Static analysis
|
||||||
|
clang-tidy --checks='*,-llvmlibc-*' src/*.cpp -- -std=c++17
|
||||||
|
|
||||||
|
# Additional analysis
|
||||||
|
cppcheck --enable=all --suppress=missingIncludeSystem src/
|
||||||
|
|
||||||
|
# Build with warnings
|
||||||
|
cmake --build build -- -Wall -Wextra -Wpedantic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```text
|
||||||
|
User: /cpp-review
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# C++ Code Review Report
|
||||||
|
|
||||||
|
## Files Reviewed
|
||||||
|
- src/handler/user.cpp (modified)
|
||||||
|
- src/service/auth.cpp (modified)
|
||||||
|
|
||||||
|
## Static Analysis Results
|
||||||
|
✓ clang-tidy: 2 warnings
|
||||||
|
✓ cppcheck: No issues
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
[CRITICAL] Memory Leak
|
||||||
|
File: src/service/auth.cpp:45
|
||||||
|
Issue: Raw `new` without matching `delete`
|
||||||
|
```cpp
|
||||||
|
auto* session = new Session(userId); // Memory leak!
|
||||||
|
cache[userId] = session;
|
||||||
|
```
|
||||||
|
Fix: Use `std::unique_ptr`
|
||||||
|
```cpp
|
||||||
|
auto session = std::make_unique<Session>(userId);
|
||||||
|
cache[userId] = std::move(session);
|
||||||
|
```
|
||||||
|
|
||||||
|
[HIGH] Missing const Reference
|
||||||
|
File: src/handler/user.cpp:28
|
||||||
|
Issue: Large object passed by value
|
||||||
|
```cpp
|
||||||
|
void processUser(User user) { // Unnecessary copy
|
||||||
|
```
|
||||||
|
Fix: Pass by const reference
|
||||||
|
```cpp
|
||||||
|
void processUser(const User& user) {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- CRITICAL: 1
|
||||||
|
- HIGH: 1
|
||||||
|
- MEDIUM: 0
|
||||||
|
|
||||||
|
Recommendation: ❌ Block merge until CRITICAL issue is fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Approval Criteria
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| ✅ Approve | No CRITICAL or HIGH issues |
|
||||||
|
| ⚠️ Warning | Only MEDIUM issues (merge with caution) |
|
||||||
|
| ❌ Block | CRITICAL or HIGH issues found |
|
||||||
|
|
||||||
|
## Integration with Other Commands
|
||||||
|
|
||||||
|
- Use `/cpp-test` first to ensure tests pass
|
||||||
|
- Use `/cpp-build` if build errors occur
|
||||||
|
- Use `/cpp-review` before committing
|
||||||
|
- Use `/code-review` for non-C++ specific concerns
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Agent: `agents/cpp-reviewer.md`
|
||||||
|
- Skills: `skills/cpp-coding-standards/`, `skills/cpp-testing/`
|
||||||
251
commands/cpp-test.md
Normal file
251
commands/cpp-test.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
description: Enforce TDD workflow for C++. Write GoogleTest tests first, then implement. Verify coverage with gcov/lcov.
|
||||||
|
---
|
||||||
|
|
||||||
|
# C++ TDD Command
|
||||||
|
|
||||||
|
This command enforces test-driven development methodology for C++ code using GoogleTest/GoogleMock with CMake/CTest.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Define Interfaces**: Scaffold class/function signatures first
|
||||||
|
2. **Write Tests**: Create comprehensive GoogleTest test cases (RED)
|
||||||
|
3. **Run Tests**: Verify tests fail for the right reason
|
||||||
|
4. **Implement Code**: Write minimal code to pass (GREEN)
|
||||||
|
5. **Refactor**: Improve while keeping tests green
|
||||||
|
6. **Check Coverage**: Ensure 80%+ coverage
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/cpp-test` when:
|
||||||
|
- Implementing new C++ functions or classes
|
||||||
|
- Adding test coverage to existing code
|
||||||
|
- Fixing bugs (write failing test first)
|
||||||
|
- Building critical business logic
|
||||||
|
- Learning TDD workflow in C++
|
||||||
|
|
||||||
|
## TDD Cycle
|
||||||
|
|
||||||
|
```
|
||||||
|
RED → Write failing GoogleTest test
|
||||||
|
GREEN → Implement minimal code to pass
|
||||||
|
REFACTOR → Improve code, tests stay green
|
||||||
|
REPEAT → Next test case
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
````
|
||||||
|
User: /cpp-test I need a function to validate email addresses
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# TDD Session: Email Validator
|
||||||
|
|
||||||
|
## Step 1: Define Interface
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// validator/email.hpp
|
||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <expected>
|
||||||
|
|
||||||
|
enum class EmailError {
|
||||||
|
Empty,
|
||||||
|
InvalidFormat
|
||||||
|
};
|
||||||
|
|
||||||
|
std::expected<void, EmailError> validate_email(const std::string& email);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Write Tests (RED)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// validator/email_test.cpp
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include "email.hpp"
|
||||||
|
|
||||||
|
TEST(ValidateEmail, AcceptsSimpleEmail) {
|
||||||
|
auto result = validate_email("user@example.com");
|
||||||
|
EXPECT_TRUE(result.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, AcceptsSubdomain) {
|
||||||
|
EXPECT_TRUE(validate_email("user@mail.example.com").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, AcceptsPlus) {
|
||||||
|
EXPECT_TRUE(validate_email("user+tag@example.com").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, RejectsEmpty) {
|
||||||
|
auto result = validate_email("");
|
||||||
|
ASSERT_FALSE(result.has_value());
|
||||||
|
EXPECT_EQ(result.error(), EmailError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, RejectsNoAtSign) {
|
||||||
|
EXPECT_FALSE(validate_email("userexample.com").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, RejectsNoDomain) {
|
||||||
|
EXPECT_FALSE(validate_email("user@").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ValidateEmail, RejectsNoLocalPart) {
|
||||||
|
EXPECT_FALSE(validate_email("@example.com").has_value());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Run Tests - Verify FAIL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build && ctest --test-dir build --output-on-failure
|
||||||
|
|
||||||
|
1/1 Test #1: email_validator_test .....***Failed
|
||||||
|
--- undefined reference to `validate_email`
|
||||||
|
|
||||||
|
FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ Tests fail as expected (unimplemented).
|
||||||
|
|
||||||
|
## Step 4: Implement Minimal Code (GREEN)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// validator/email.cpp
|
||||||
|
#include "email.hpp"
|
||||||
|
#include <regex>
|
||||||
|
|
||||||
|
std::expected<void, EmailError> validate_email(const std::string& email) {
|
||||||
|
if (email.empty()) {
|
||||||
|
return std::unexpected(EmailError::Empty);
|
||||||
|
}
|
||||||
|
static const std::regex pattern(R"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})");
|
||||||
|
if (!std::regex_match(email, pattern)) {
|
||||||
|
return std::unexpected(EmailError::InvalidFormat);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Run Tests - Verify PASS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake --build build && ctest --test-dir build --output-on-failure
|
||||||
|
|
||||||
|
1/1 Test #1: email_validator_test ..... Passed 0.01 sec
|
||||||
|
|
||||||
|
100% tests passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ All tests passing!
|
||||||
|
|
||||||
|
## Step 6: Check Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cmake -DCMAKE_CXX_FLAGS="--coverage" -B build && cmake --build build
|
||||||
|
$ ctest --test-dir build
|
||||||
|
$ lcov --capture --directory build --output-file coverage.info
|
||||||
|
$ lcov --list coverage.info
|
||||||
|
|
||||||
|
validator/email.cpp | 100%
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ Coverage: 100%
|
||||||
|
|
||||||
|
## TDD Complete!
|
||||||
|
````
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Basic Tests
|
||||||
|
```cpp
|
||||||
|
TEST(SuiteName, TestName) {
|
||||||
|
EXPECT_EQ(add(2, 3), 5);
|
||||||
|
EXPECT_NE(result, nullptr);
|
||||||
|
EXPECT_TRUE(is_valid);
|
||||||
|
EXPECT_THROW(func(), std::invalid_argument);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixtures
|
||||||
|
```cpp
|
||||||
|
class DatabaseTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override { db_ = create_test_db(); }
|
||||||
|
void TearDown() override { db_.reset(); }
|
||||||
|
std::unique_ptr<Database> db_;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(DatabaseTest, InsertsRecord) {
|
||||||
|
db_->insert("key", "value");
|
||||||
|
EXPECT_EQ(db_->get("key"), "value");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameterized Tests
|
||||||
|
```cpp
|
||||||
|
class PrimeTest : public ::testing::TestWithParam<std::pair<int, bool>> {};
|
||||||
|
|
||||||
|
TEST_P(PrimeTest, ChecksPrimality) {
|
||||||
|
auto [input, expected] = GetParam();
|
||||||
|
EXPECT_EQ(is_prime(input), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
INSTANTIATE_TEST_SUITE_P(Primes, PrimeTest, ::testing::Values(
|
||||||
|
std::make_pair(2, true),
|
||||||
|
std::make_pair(4, false),
|
||||||
|
std::make_pair(7, true)
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with coverage
|
||||||
|
cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" -B build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cmake --build build && ctest --test-dir build
|
||||||
|
|
||||||
|
# Generate coverage report
|
||||||
|
lcov --capture --directory build --output-file coverage.info
|
||||||
|
lcov --remove coverage.info '/usr/*' --output-file coverage.info
|
||||||
|
genhtml coverage.info --output-directory coverage_html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Targets
|
||||||
|
|
||||||
|
| Code Type | Target |
|
||||||
|
|-----------|--------|
|
||||||
|
| Critical business logic | 100% |
|
||||||
|
| Public APIs | 90%+ |
|
||||||
|
| General code | 80%+ |
|
||||||
|
| Generated code | Exclude |
|
||||||
|
|
||||||
|
## TDD Best Practices
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- Write test FIRST, before any implementation
|
||||||
|
- Run tests after each change
|
||||||
|
- Use `EXPECT_*` (continues) over `ASSERT_*` (stops) when appropriate
|
||||||
|
- Test behavior, not implementation details
|
||||||
|
- Include edge cases (empty, null, max values, boundary conditions)
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- Write implementation before tests
|
||||||
|
- Skip the RED phase
|
||||||
|
- Test private methods directly (test through public API)
|
||||||
|
- Use `sleep` in tests
|
||||||
|
- Ignore flaky tests
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
- `/cpp-build` - Fix build errors
|
||||||
|
- `/cpp-review` - Review code after implementation
|
||||||
|
- `/verify` - Run full verification loop
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Skill: `skills/cpp-testing/`
|
||||||
|
- Skill: `skills/tdd-workflow/`
|
||||||
44
rules/cpp/coding-style.md
Normal file
44
rules/cpp/coding-style.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.cpp"
|
||||||
|
- "**/*.hpp"
|
||||||
|
- "**/*.cc"
|
||||||
|
- "**/*.hh"
|
||||||
|
- "**/*.cxx"
|
||||||
|
- "**/*.h"
|
||||||
|
- "**/CMakeLists.txt"
|
||||||
|
---
|
||||||
|
# C++ Coding Style
|
||||||
|
|
||||||
|
> This file extends [common/coding-style.md](../common/coding-style.md) with C++ specific content.
|
||||||
|
|
||||||
|
## Modern C++ (C++17/20/23)
|
||||||
|
|
||||||
|
- Prefer **modern C++ features** over C-style constructs
|
||||||
|
- Use `auto` when the type is obvious from context
|
||||||
|
- Use `constexpr` for compile-time constants
|
||||||
|
- Use structured bindings: `auto [key, value] = map_entry;`
|
||||||
|
|
||||||
|
## Resource Management
|
||||||
|
|
||||||
|
- **RAII everywhere** — no manual `new`/`delete`
|
||||||
|
- Use `std::unique_ptr` for exclusive ownership
|
||||||
|
- Use `std::shared_ptr` only when shared ownership is truly needed
|
||||||
|
- Use `std::make_unique` / `std::make_shared` over raw `new`
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Types/Classes: `PascalCase`
|
||||||
|
- Functions/Methods: `snake_case` or `camelCase` (follow project convention)
|
||||||
|
- Constants: `kPascalCase` or `UPPER_SNAKE_CASE`
|
||||||
|
- Namespaces: `lowercase`
|
||||||
|
- Member variables: `snake_case_` (trailing underscore) or `m_` prefix
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
- Use **clang-format** — no style debates
|
||||||
|
- Run `clang-format -i <file>` before committing
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `cpp-coding-standards` for comprehensive C++ coding standards and guidelines.
|
||||||
39
rules/cpp/hooks.md
Normal file
39
rules/cpp/hooks.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.cpp"
|
||||||
|
- "**/*.hpp"
|
||||||
|
- "**/*.cc"
|
||||||
|
- "**/*.hh"
|
||||||
|
- "**/*.cxx"
|
||||||
|
- "**/*.h"
|
||||||
|
- "**/CMakeLists.txt"
|
||||||
|
---
|
||||||
|
# C++ Hooks
|
||||||
|
|
||||||
|
> This file extends [common/hooks.md](../common/hooks.md) with C++ specific content.
|
||||||
|
|
||||||
|
## Build Hooks
|
||||||
|
|
||||||
|
Run these checks before committing C++ changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format check
|
||||||
|
clang-format --dry-run --Werror src/*.cpp src/*.hpp
|
||||||
|
|
||||||
|
# Static analysis
|
||||||
|
clang-tidy src/*.cpp -- -std=c++17
|
||||||
|
|
||||||
|
# Build
|
||||||
|
cmake --build build
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended CI Pipeline
|
||||||
|
|
||||||
|
1. **clang-format** — formatting check
|
||||||
|
2. **clang-tidy** — static analysis
|
||||||
|
3. **cppcheck** — additional analysis
|
||||||
|
4. **cmake build** — compilation
|
||||||
|
5. **ctest** — test execution with sanitizers
|
||||||
51
rules/cpp/patterns.md
Normal file
51
rules/cpp/patterns.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.cpp"
|
||||||
|
- "**/*.hpp"
|
||||||
|
- "**/*.cc"
|
||||||
|
- "**/*.hh"
|
||||||
|
- "**/*.cxx"
|
||||||
|
- "**/*.h"
|
||||||
|
- "**/CMakeLists.txt"
|
||||||
|
---
|
||||||
|
# C++ Patterns
|
||||||
|
|
||||||
|
> This file extends [common/patterns.md](../common/patterns.md) with C++ specific content.
|
||||||
|
|
||||||
|
## RAII (Resource Acquisition Is Initialization)
|
||||||
|
|
||||||
|
Tie resource lifetime to object lifetime:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class FileHandle {
|
||||||
|
public:
|
||||||
|
explicit FileHandle(const std::string& path) : file_(std::fopen(path.c_str(), "r")) {}
|
||||||
|
~FileHandle() { if (file_) std::fclose(file_); }
|
||||||
|
FileHandle(const FileHandle&) = delete;
|
||||||
|
FileHandle& operator=(const FileHandle&) = delete;
|
||||||
|
private:
|
||||||
|
std::FILE* file_;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule of Five/Zero
|
||||||
|
|
||||||
|
- **Rule of Zero**: Prefer classes that need no custom destructor, copy/move constructors, or assignments
|
||||||
|
- **Rule of Five**: If you define any of destructor/copy-ctor/copy-assign/move-ctor/move-assign, define all five
|
||||||
|
|
||||||
|
## Value Semantics
|
||||||
|
|
||||||
|
- Pass small/trivial types by value
|
||||||
|
- Pass large types by `const&`
|
||||||
|
- Return by value (rely on RVO/NRVO)
|
||||||
|
- Use move semantics for sink parameters
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Use exceptions for exceptional conditions
|
||||||
|
- Use `std::optional` for values that may not exist
|
||||||
|
- Use `std::expected` (C++23) or result types for expected failures
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `cpp-coding-standards` for comprehensive C++ patterns and anti-patterns.
|
||||||
51
rules/cpp/security.md
Normal file
51
rules/cpp/security.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.cpp"
|
||||||
|
- "**/*.hpp"
|
||||||
|
- "**/*.cc"
|
||||||
|
- "**/*.hh"
|
||||||
|
- "**/*.cxx"
|
||||||
|
- "**/*.h"
|
||||||
|
- "**/CMakeLists.txt"
|
||||||
|
---
|
||||||
|
# C++ Security
|
||||||
|
|
||||||
|
> This file extends [common/security.md](../common/security.md) with C++ specific content.
|
||||||
|
|
||||||
|
## Memory Safety
|
||||||
|
|
||||||
|
- Never use raw `new`/`delete` — use smart pointers
|
||||||
|
- Never use C-style arrays — use `std::array` or `std::vector`
|
||||||
|
- Never use `malloc`/`free` — use C++ allocation
|
||||||
|
- Avoid `reinterpret_cast` unless absolutely necessary
|
||||||
|
|
||||||
|
## Buffer Overflows
|
||||||
|
|
||||||
|
- Use `std::string` over `char*`
|
||||||
|
- Use `.at()` for bounds-checked access when safety matters
|
||||||
|
- Never use `strcpy`, `strcat`, `sprintf` — use `std::string` or `fmt::format`
|
||||||
|
|
||||||
|
## Undefined Behavior
|
||||||
|
|
||||||
|
- Always initialize variables
|
||||||
|
- Avoid signed integer overflow
|
||||||
|
- Never dereference null or dangling pointers
|
||||||
|
- Use sanitizers in CI:
|
||||||
|
```bash
|
||||||
|
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static Analysis
|
||||||
|
|
||||||
|
- Use **clang-tidy** for automated checks:
|
||||||
|
```bash
|
||||||
|
clang-tidy --checks='*' src/*.cpp
|
||||||
|
```
|
||||||
|
- Use **cppcheck** for additional analysis:
|
||||||
|
```bash
|
||||||
|
cppcheck --enable=all src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `cpp-coding-standards` for detailed security guidelines.
|
||||||
44
rules/cpp/testing.md
Normal file
44
rules/cpp/testing.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.cpp"
|
||||||
|
- "**/*.hpp"
|
||||||
|
- "**/*.cc"
|
||||||
|
- "**/*.hh"
|
||||||
|
- "**/*.cxx"
|
||||||
|
- "**/*.h"
|
||||||
|
- "**/CMakeLists.txt"
|
||||||
|
---
|
||||||
|
# C++ Testing
|
||||||
|
|
||||||
|
> This file extends [common/testing.md](../common/testing.md) with C++ specific content.
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
Use **GoogleTest** (gtest/gmock) with **CMake/CTest**.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build && ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" ..
|
||||||
|
cmake --build .
|
||||||
|
ctest --output-on-failure
|
||||||
|
lcov --capture --directory . --output-file coverage.info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sanitizers
|
||||||
|
|
||||||
|
Always run tests with sanitizers in CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `cpp-testing` for detailed C++ testing patterns, TDD workflow, and GoogleTest/GMock usage.
|
||||||
145
tests/hooks/auto-tmux-dev.test.js
Normal file
145
tests/hooks/auto-tmux-dev.test.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/hooks/auto-tmux-dev.js
|
||||||
|
*
|
||||||
|
* Tests dev server command transformation for tmux wrapping.
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/auto-tmux-dev.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'auto-tmux-dev.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` \u2717 ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(input) {
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: typeof input === 'string' ? input : JSON.stringify(input),
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: result.status || 0,
|
||||||
|
stdout: result.stdout || '',
|
||||||
|
stderr: result.stderr || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing auto-tmux-dev.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// Check if tmux is available for conditional tests
|
||||||
|
const tmuxAvailable = spawnSync('which', ['tmux'], { encoding: 'utf8' }).status === 0;
|
||||||
|
|
||||||
|
console.log('Dev server detection:');
|
||||||
|
|
||||||
|
if (test('transforms npm run dev command', () => {
|
||||||
|
const result = runScript({ tool_input: { command: 'npm run dev' } });
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||||
|
assert.ok(output.tool_input.command.includes('tmux'), 'Should contain tmux');
|
||||||
|
assert.ok(output.tool_input.command.includes('npm run dev'), 'Should contain original command');
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('transforms pnpm dev command', () => {
|
||||||
|
const result = runScript({ tool_input: { command: 'pnpm dev' } });
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||||
|
assert.ok(output.tool_input.command.includes('tmux'));
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('transforms yarn dev command', () => {
|
||||||
|
const result = runScript({ tool_input: { command: 'yarn dev' } });
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||||
|
assert.ok(output.tool_input.command.includes('tmux'));
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('transforms bun run dev command', () => {
|
||||||
|
const result = runScript({ tool_input: { command: 'bun run dev' } });
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||||
|
assert.ok(output.tool_input.command.includes('tmux'));
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nNon-dev commands (pass-through):');
|
||||||
|
|
||||||
|
if (test('does not transform npm install', () => {
|
||||||
|
const input = { tool_input: { command: 'npm install' } };
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(output.tool_input.command, 'npm install');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('does not transform npm test', () => {
|
||||||
|
const input = { tool_input: { command: 'npm test' } };
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(output.tool_input.command, 'npm test');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('does not transform npm run build', () => {
|
||||||
|
const input = { tool_input: { command: 'npm run build' } };
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(output.tool_input.command, 'npm run build');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('does not transform npm run develop (partial match)', () => {
|
||||||
|
const input = { tool_input: { command: 'npm run develop' } };
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(output.tool_input.command, 'npm run develop');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nEdge cases:');
|
||||||
|
|
||||||
|
if (test('handles empty input gracefully', () => {
|
||||||
|
const result = runScript('{}');
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles invalid JSON gracefully', () => {
|
||||||
|
const result = runScript('not json');
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
assert.strictEqual(result.stdout, 'not json');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('passes through missing command field', () => {
|
||||||
|
const input = { tool_input: {} };
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
108
tests/hooks/check-hook-enabled.test.js
Normal file
108
tests/hooks/check-hook-enabled.test.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/hooks/check-hook-enabled.js
|
||||||
|
*
|
||||||
|
* Tests the CLI wrapper around isHookEnabled.
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/check-hook-enabled.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'check-hook-enabled.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` \u2717 ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(args = [], envOverrides = {}) {
|
||||||
|
const env = { ...process.env, ...envOverrides };
|
||||||
|
// Remove potentially interfering env vars unless explicitly set
|
||||||
|
if (!envOverrides.ECC_HOOK_PROFILE) delete env.ECC_HOOK_PROFILE;
|
||||||
|
if (!envOverrides.ECC_DISABLED_HOOKS) delete env.ECC_DISABLED_HOOKS;
|
||||||
|
|
||||||
|
const result = spawnSync('node', [script, ...args], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10000,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: result.status || 0,
|
||||||
|
stdout: result.stdout || '',
|
||||||
|
stderr: result.stderr || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing check-hook-enabled.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
console.log('No arguments:');
|
||||||
|
|
||||||
|
if (test('returns yes when no hookId provided', () => {
|
||||||
|
const result = runScript([]);
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nDefault profile (standard):');
|
||||||
|
|
||||||
|
if (test('returns yes for hook with default profiles', () => {
|
||||||
|
const result = runScript(['my-hook']);
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns yes for hook with standard,strict profiles', () => {
|
||||||
|
const result = runScript(['my-hook', 'standard,strict']);
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns no for hook with only strict profile', () => {
|
||||||
|
const result = runScript(['my-hook', 'strict']);
|
||||||
|
assert.strictEqual(result.stdout, 'no');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns no for hook with only minimal profile', () => {
|
||||||
|
const result = runScript(['my-hook', 'minimal']);
|
||||||
|
assert.strictEqual(result.stdout, 'no');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nDisabled hooks:');
|
||||||
|
|
||||||
|
if (test('returns no when hook is disabled via env', () => {
|
||||||
|
const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'my-hook' });
|
||||||
|
assert.strictEqual(result.stdout, 'no');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns yes when different hook is disabled', () => {
|
||||||
|
const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'other-hook' });
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nProfile overrides:');
|
||||||
|
|
||||||
|
if (test('returns yes for strict profile with strict-only hook', () => {
|
||||||
|
const result = runScript(['my-hook', 'strict'], { ECC_HOOK_PROFILE: 'strict' });
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns yes for minimal profile with minimal-only hook', () => {
|
||||||
|
const result = runScript(['my-hook', 'minimal'], { ECC_HOOK_PROFILE: 'minimal' });
|
||||||
|
assert.strictEqual(result.stdout, 'yes');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
131
tests/hooks/cost-tracker.test.js
Normal file
131
tests/hooks/cost-tracker.test.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Tests for cost-tracker.js hook
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/cost-tracker.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTempDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'cost-tracker-test-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(input, envOverrides = {}) {
|
||||||
|
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: inputStr,
|
||||||
|
timeout: 10000,
|
||||||
|
env: { ...process.env, ...envOverrides },
|
||||||
|
});
|
||||||
|
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing cost-tracker.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// 1. Passes through input on stdout
|
||||||
|
(test('passes through input on stdout', () => {
|
||||||
|
const input = {
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
usage: { input_tokens: 100, output_tokens: 50 },
|
||||||
|
};
|
||||||
|
const inputStr = JSON.stringify(input);
|
||||||
|
const result = runScript(input);
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 2. Creates metrics file when given valid usage data
|
||||||
|
(test('creates metrics file when given valid usage data', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const input = {
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
usage: { input_tokens: 1000, output_tokens: 500 },
|
||||||
|
};
|
||||||
|
const result = runScript(input, { HOME: tmpHome });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
|
||||||
|
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||||
|
assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(metricsFile, 'utf8').trim();
|
||||||
|
const row = JSON.parse(content);
|
||||||
|
assert.strictEqual(row.input_tokens, 1000, 'Expected input_tokens to be 1000');
|
||||||
|
assert.strictEqual(row.output_tokens, 500, 'Expected output_tokens to be 500');
|
||||||
|
assert.ok(row.timestamp, 'Expected timestamp to be present');
|
||||||
|
assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number');
|
||||||
|
assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive');
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 3. Handles empty input gracefully
|
||||||
|
(test('handles empty input gracefully', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const result = runScript('', { HOME: tmpHome });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
// stdout should be empty since input was empty
|
||||||
|
assert.strictEqual(result.stdout, '', 'Expected empty stdout for empty input');
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 4. Handles invalid JSON gracefully
|
||||||
|
(test('handles invalid JSON gracefully', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const invalidInput = 'not valid json {{{';
|
||||||
|
const result = runScript(invalidInput, { HOME: tmpHome });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
// Should still pass through the raw input on stdout
|
||||||
|
assert.strictEqual(result.stdout, invalidInput, 'Expected stdout to contain original invalid input');
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 5. Handles missing usage fields gracefully
|
||||||
|
(test('handles missing usage fields gracefully', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const input = { model: 'claude-sonnet-4-20250514' };
|
||||||
|
const inputStr = JSON.stringify(input);
|
||||||
|
const result = runScript(input, { HOME: tmpHome });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||||
|
|
||||||
|
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||||
|
assert.ok(fs.existsSync(metricsFile), 'Expected metrics file to exist even with missing usage');
|
||||||
|
|
||||||
|
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||||
|
assert.strictEqual(row.input_tokens, 0, 'Expected input_tokens to be 0 when missing');
|
||||||
|
assert.strictEqual(row.output_tokens, 0, 'Expected output_tokens to be 0 when missing');
|
||||||
|
assert.strictEqual(row.estimated_cost_usd, 0, 'Expected estimated_cost_usd to be 0 when no tokens');
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
152
tests/hooks/doc-file-warning.test.js
Normal file
152
tests/hooks/doc-file-warning.test.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'doc-file-warning.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(input) {
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: JSON.stringify(input),
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing doc-file-warning.js ===\n');
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// 1. Allowed standard doc files - no warning in stderr
|
||||||
|
const standardFiles = [
|
||||||
|
'README.md',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'LICENSE.md',
|
||||||
|
'SKILL.md',
|
||||||
|
'MEMORY.md',
|
||||||
|
'WORKLOG.md',
|
||||||
|
];
|
||||||
|
for (const file of standardFiles) {
|
||||||
|
(test(`allows standard doc file: ${file}`, () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||||
|
assert.strictEqual(code, 0, `expected exit code 0, got ${code}`);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Allowed directory paths - no warning
|
||||||
|
const allowedDirPaths = [
|
||||||
|
'docs/foo.md',
|
||||||
|
'docs/guide/setup.md',
|
||||||
|
'skills/bar.md',
|
||||||
|
'skills/testing/tdd.md',
|
||||||
|
'.history/session.md',
|
||||||
|
'memory/patterns.md',
|
||||||
|
'.claude/commands/deploy.md',
|
||||||
|
'.claude/plans/roadmap.md',
|
||||||
|
'.claude/projects/myproject.md',
|
||||||
|
];
|
||||||
|
for (const file of allowedDirPaths) {
|
||||||
|
(test(`allows directory path: ${file}`, () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||||
|
assert.strictEqual(code, 0, `expected exit code 0, got ${code}`);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Allowed .plan.md files - no warning
|
||||||
|
(test('allows .plan.md files', () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: 'feature.plan.md' } });
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for .plan.md, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows nested .plan.md files', () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: 'src/refactor.plan.md' } });
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for nested .plan.md, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 4. Non-md/txt files always pass - no warning
|
||||||
|
const nonDocFiles = ['foo.js', 'app.py', 'styles.css', 'data.json', 'image.png'];
|
||||||
|
for (const file of nonDocFiles) {
|
||||||
|
(test(`allows non-doc file: ${file}`, () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Non-standard doc files - warning in stderr
|
||||||
|
const nonStandardFiles = ['random-notes.md', 'TODO.md', 'notes.txt', 'scratch.md', 'ideas.txt'];
|
||||||
|
for (const file of nonStandardFiles) {
|
||||||
|
(test(`warns on non-standard doc file: ${file}`, () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||||
|
assert.strictEqual(code, 0, 'should still exit 0 (warn only)');
|
||||||
|
assert.ok(stderr.includes('WARNING'), `expected warning in stderr for ${file}, got: ${stderr}`);
|
||||||
|
assert.ok(stderr.includes(file), `expected file path in stderr for ${file}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Invalid/empty input - passes through without error
|
||||||
|
(test('handles empty object input without error', () => {
|
||||||
|
const { code, stderr } = runScript({});
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for empty input, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('handles missing file_path without error', () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: {} });
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for missing file_path, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('handles empty file_path without error', () => {
|
||||||
|
const { code, stderr } = runScript({ tool_input: { file_path: '' } });
|
||||||
|
assert.strictEqual(code, 0);
|
||||||
|
assert.strictEqual(stderr, '', `expected no warning for empty file_path, got: ${stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// 7. Stdout always contains the original input (pass-through)
|
||||||
|
(test('passes through input to stdout for allowed file', () => {
|
||||||
|
const input = { tool_input: { file_path: 'README.md' } };
|
||||||
|
const { stdout } = runScript(input);
|
||||||
|
assert.strictEqual(stdout, JSON.stringify(input));
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('passes through input to stdout for warned file', () => {
|
||||||
|
const input = { tool_input: { file_path: 'random-notes.md' } };
|
||||||
|
const { stdout } = runScript(input);
|
||||||
|
assert.strictEqual(stdout, JSON.stringify(input));
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('passes through input to stdout for empty input', () => {
|
||||||
|
const input = {};
|
||||||
|
const { stdout } = runScript(input);
|
||||||
|
assert.strictEqual(stdout, JSON.stringify(input));
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
397
tests/hooks/hook-flags.test.js
Normal file
397
tests/hooks/hook-flags.test.js
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/lib/hook-flags.js
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/hook-flags.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
// Import the module
|
||||||
|
const {
|
||||||
|
VALID_PROFILES,
|
||||||
|
normalizeId,
|
||||||
|
getHookProfile,
|
||||||
|
getDisabledHookIds,
|
||||||
|
parseProfiles,
|
||||||
|
isHookEnabled,
|
||||||
|
} = require('../../scripts/lib/hook-flags');
|
||||||
|
|
||||||
|
// Test helper
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to save and restore env vars
|
||||||
|
function withEnv(vars, fn) {
|
||||||
|
const saved = {};
|
||||||
|
for (const key of Object.keys(vars)) {
|
||||||
|
saved[key] = process.env[key];
|
||||||
|
if (vars[key] === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = vars[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
for (const key of Object.keys(saved)) {
|
||||||
|
if (saved[key] === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = saved[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test suite
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing hook-flags.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// VALID_PROFILES tests
|
||||||
|
console.log('VALID_PROFILES:');
|
||||||
|
|
||||||
|
if (test('is a Set', () => {
|
||||||
|
assert.ok(VALID_PROFILES instanceof Set);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('contains minimal, standard, strict', () => {
|
||||||
|
assert.ok(VALID_PROFILES.has('minimal'));
|
||||||
|
assert.ok(VALID_PROFILES.has('standard'));
|
||||||
|
assert.ok(VALID_PROFILES.has('strict'));
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('contains exactly 3 profiles', () => {
|
||||||
|
assert.strictEqual(VALID_PROFILES.size, 3);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// normalizeId tests
|
||||||
|
console.log('\nnormalizeId:');
|
||||||
|
|
||||||
|
if (test('returns empty string for null', () => {
|
||||||
|
assert.strictEqual(normalizeId(null), '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns empty string for undefined', () => {
|
||||||
|
assert.strictEqual(normalizeId(undefined), '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns empty string for empty string', () => {
|
||||||
|
assert.strictEqual(normalizeId(''), '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('trims whitespace', () => {
|
||||||
|
assert.strictEqual(normalizeId(' hello '), 'hello');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('converts to lowercase', () => {
|
||||||
|
assert.strictEqual(normalizeId('MyHook'), 'myhook');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles mixed case with whitespace', () => {
|
||||||
|
assert.strictEqual(normalizeId(' My-Hook-ID '), 'my-hook-id');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('converts numbers to string', () => {
|
||||||
|
assert.strictEqual(normalizeId(123), '123');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns empty string for whitespace-only input', () => {
|
||||||
|
assert.strictEqual(normalizeId(' '), '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// getHookProfile tests
|
||||||
|
console.log('\ngetHookProfile:');
|
||||||
|
|
||||||
|
if (test('defaults to standard when env var not set', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'standard');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns minimal when set to minimal', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'minimal' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'minimal');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns standard when set to standard', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'standard' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'standard');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns strict when set to strict', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'strict' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'strict');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('is case-insensitive', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'STRICT' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'strict');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('trims whitespace from env var', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: ' minimal ' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'minimal');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('defaults to standard for invalid value', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'invalid' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'standard');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('defaults to standard for empty string', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: '' }, () => {
|
||||||
|
assert.strictEqual(getHookProfile(), 'standard');
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// getDisabledHookIds tests
|
||||||
|
console.log('\ngetDisabledHookIds:');
|
||||||
|
|
||||||
|
if (test('returns empty Set when env var not set', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.ok(result instanceof Set);
|
||||||
|
assert.strictEqual(result.size, 0);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns empty Set for empty string', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: '' }, () => {
|
||||||
|
assert.strictEqual(getDisabledHookIds().size, 0);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns empty Set for whitespace-only string', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: ' ' }, () => {
|
||||||
|
assert.strictEqual(getDisabledHookIds().size, 0);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('parses single hook id', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.strictEqual(result.size, 1);
|
||||||
|
assert.ok(result.has('my-hook'));
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('parses multiple comma-separated hook ids', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: 'hook-a,hook-b,hook-c' }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.strictEqual(result.size, 3);
|
||||||
|
assert.ok(result.has('hook-a'));
|
||||||
|
assert.ok(result.has('hook-b'));
|
||||||
|
assert.ok(result.has('hook-c'));
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('trims whitespace around hook ids', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: ' hook-a , hook-b ' }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.strictEqual(result.size, 2);
|
||||||
|
assert.ok(result.has('hook-a'));
|
||||||
|
assert.ok(result.has('hook-b'));
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('normalizes hook ids to lowercase', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: 'MyHook,ANOTHER' }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.ok(result.has('myhook'));
|
||||||
|
assert.ok(result.has('another'));
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('filters out empty entries from trailing commas', () => {
|
||||||
|
withEnv({ ECC_DISABLED_HOOKS: 'hook-a,,hook-b,' }, () => {
|
||||||
|
const result = getDisabledHookIds();
|
||||||
|
assert.strictEqual(result.size, 2);
|
||||||
|
assert.ok(result.has('hook-a'));
|
||||||
|
assert.ok(result.has('hook-b'));
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// parseProfiles tests
|
||||||
|
console.log('\nparseProfiles:');
|
||||||
|
|
||||||
|
if (test('returns fallback for null input', () => {
|
||||||
|
const result = parseProfiles(null);
|
||||||
|
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns fallback for undefined input', () => {
|
||||||
|
const result = parseProfiles(undefined);
|
||||||
|
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uses custom fallback when provided', () => {
|
||||||
|
const result = parseProfiles(null, ['minimal']);
|
||||||
|
assert.deepStrictEqual(result, ['minimal']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('parses comma-separated string', () => {
|
||||||
|
const result = parseProfiles('minimal,strict');
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('parses single string value', () => {
|
||||||
|
const result = parseProfiles('strict');
|
||||||
|
assert.deepStrictEqual(result, ['strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('parses array of profiles', () => {
|
||||||
|
const result = parseProfiles(['minimal', 'standard']);
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'standard']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('filters invalid profiles from string', () => {
|
||||||
|
const result = parseProfiles('minimal,invalid,strict');
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('filters invalid profiles from array', () => {
|
||||||
|
const result = parseProfiles(['minimal', 'bogus', 'strict']);
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns fallback when all string values are invalid', () => {
|
||||||
|
const result = parseProfiles('invalid,bogus');
|
||||||
|
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns fallback when all array values are invalid', () => {
|
||||||
|
const result = parseProfiles(['invalid', 'bogus']);
|
||||||
|
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('is case-insensitive for string input', () => {
|
||||||
|
const result = parseProfiles('MINIMAL,STRICT');
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('is case-insensitive for array input', () => {
|
||||||
|
const result = parseProfiles(['MINIMAL', 'STRICT']);
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('trims whitespace in string input', () => {
|
||||||
|
const result = parseProfiles(' minimal , strict ');
|
||||||
|
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles null values in array', () => {
|
||||||
|
const result = parseProfiles([null, 'strict']);
|
||||||
|
assert.deepStrictEqual(result, ['strict']);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// isHookEnabled tests
|
||||||
|
console.log('\nisHookEnabled:');
|
||||||
|
|
||||||
|
if (test('returns true by default for a hook (standard profile)', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns true for empty hookId', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled(''), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns true for null hookId', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled(null), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns false when hook is in disabled list', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('disabled check is case-insensitive', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'MY-HOOK' }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns true when hook is not in disabled list', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'other-hook' }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns false when current profile is not in allowed profiles', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns true when current profile is in allowed profiles', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'standard,strict' }), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns true when current profile matches single allowed profile', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'minimal' }), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('disabled hooks take precedence over profile match', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uses default profiles (standard, strict) when none specified', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('allows standard profile by default', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('allows strict profile by default', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('accepts array profiles option', () => {
|
||||||
|
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||||
|
assert.strictEqual(isHookEnabled('my-hook', { profiles: ['minimal', 'standard'] }), true);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
207
tests/hooks/post-bash-hooks.test.js
Normal file
207
tests/hooks/post-bash-hooks.test.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Tests for post-bash-build-complete.js and post-bash-pr-created.js
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/post-bash-hooks.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const buildCompleteScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-build-complete.js');
|
||||||
|
const prCreatedScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-pr-created.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(scriptPath, input) {
|
||||||
|
return spawnSync('node', [scriptPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// ── post-bash-build-complete.js ──────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nPost-Bash Build Complete Hook Tests');
|
||||||
|
console.log('====================================\n');
|
||||||
|
|
||||||
|
console.log('Build command detection:');
|
||||||
|
|
||||||
|
if (test('stderr contains "Build completed" for npm run build command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'npm run build' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stderr contains "Build completed" for pnpm build command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'pnpm build' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stderr contains "Build completed" for yarn build command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'yarn build' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nNon-build command detection:');
|
||||||
|
|
||||||
|
if (test('no stderr message for npm test command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('no stderr message for ls command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'ls -la' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('no stderr message for git status command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'git status' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nStdout pass-through:');
|
||||||
|
|
||||||
|
if (test('stdout passes through input for build command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'npm run build' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through input for non-build command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through input for invalid JSON', () => {
|
||||||
|
const input = 'not valid json';
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through empty input', () => {
|
||||||
|
const input = '';
|
||||||
|
const result = runScript(buildCompleteScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// ── post-bash-pr-created.js ──────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\n\nPost-Bash PR Created Hook Tests');
|
||||||
|
console.log('================================\n');
|
||||||
|
|
||||||
|
console.log('PR creation detection:');
|
||||||
|
|
||||||
|
if (test('stderr contains PR URL when gh pr create output has PR URL', () => {
|
||||||
|
const input = JSON.stringify({
|
||||||
|
tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' },
|
||||||
|
tool_output: { output: 'https://github.com/owner/repo/pull/42\n' }
|
||||||
|
});
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.ok(result.stderr.includes('https://github.com/owner/repo/pull/42'), `stderr should contain PR URL, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('[Hook] PR created:'), 'stderr should contain PR created message');
|
||||||
|
assert.ok(result.stderr.includes('gh pr review 42'), 'stderr should contain review command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stderr contains correct repo in review command', () => {
|
||||||
|
const input = JSON.stringify({
|
||||||
|
tool_input: { command: 'gh pr create' },
|
||||||
|
tool_output: { output: 'Created PR\nhttps://github.com/my-org/my-repo/pull/123\nDone' }
|
||||||
|
});
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.ok(result.stderr.includes('--repo my-org/my-repo'), `stderr should contain correct repo, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('gh pr review 123'), 'stderr should contain correct PR number');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nNon-PR command detection:');
|
||||||
|
|
||||||
|
if (test('no stderr about PR for non-gh command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('no stderr about PR for gh issue command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'gh issue list' } });
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR create command');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('no stderr about PR for gh pr create without PR URL in output', () => {
|
||||||
|
const input = JSON.stringify({
|
||||||
|
tool_input: { command: 'gh pr create' },
|
||||||
|
tool_output: { output: 'Error: could not create PR' }
|
||||||
|
});
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty when no PR URL in output');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('no stderr about PR for gh pr list command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'gh pr list' } });
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||||
|
assert.strictEqual(result.stderr, '', 'stderr should be empty for gh pr list');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log('\nStdout pass-through:');
|
||||||
|
|
||||||
|
if (test('stdout passes through input for PR create command', () => {
|
||||||
|
const input = JSON.stringify({
|
||||||
|
tool_input: { command: 'gh pr create' },
|
||||||
|
tool_output: { output: 'https://github.com/owner/repo/pull/1' }
|
||||||
|
});
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through input for non-PR command', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'echo hello' } });
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through input for invalid JSON', () => {
|
||||||
|
const input = 'not valid json';
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stdout passes through empty input', () => {
|
||||||
|
const input = '';
|
||||||
|
const result = runScript(prCreatedScript, input);
|
||||||
|
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
121
tests/hooks/pre-bash-dev-server-block.test.js
Normal file
121
tests/hooks/pre-bash-dev-server-block.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Tests for pre-bash-dev-server-block.js hook
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/pre-bash-dev-server-block.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dev-server-block.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(command) {
|
||||||
|
const input = { tool_input: { command } };
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: JSON.stringify(input),
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing pre-bash-dev-server-block.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
// --- Blocking tests (non-Windows only) ---
|
||||||
|
|
||||||
|
if (!isWindows) {
|
||||||
|
(test('blocks npm run dev (exit code 2, stderr contains BLOCKED)', () => {
|
||||||
|
const result = runScript('npm run dev');
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||||
|
assert.ok(result.stderr.includes('BLOCKED'), `Expected stderr to contain BLOCKED, got: ${result.stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('blocks pnpm dev (exit code 2)', () => {
|
||||||
|
const result = runScript('pnpm dev');
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('blocks yarn dev (exit code 2)', () => {
|
||||||
|
const result = runScript('yarn dev');
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('blocks bun run dev (exit code 2)', () => {
|
||||||
|
const result = runScript('bun run dev');
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
} else {
|
||||||
|
console.log(' (skipping blocking tests on Windows)\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Allow tests ---
|
||||||
|
|
||||||
|
(test('allows tmux-wrapped npm run dev (exit code 0)', () => {
|
||||||
|
const result = runScript('tmux new-session -d -s dev "npm run dev"');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows npm install (exit code 0)', () => {
|
||||||
|
const result = runScript('npm install');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows npm test (exit code 0)', () => {
|
||||||
|
const result = runScript('npm test');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows npm run build (exit code 0)', () => {
|
||||||
|
const result = runScript('npm run build');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// --- Edge cases ---
|
||||||
|
|
||||||
|
(test('empty/invalid input passes through (exit code 0)', () => {
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: '',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status || 0, 0, `Expected exit code 0, got ${result.status}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('stdout contains original input on pass-through', () => {
|
||||||
|
const input = { tool_input: { command: 'npm install' } };
|
||||||
|
const inputStr = JSON.stringify(input);
|
||||||
|
const result = spawnSync('node', [script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: inputStr,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status || 0, 0);
|
||||||
|
assert.strictEqual(result.stdout.trim(), inputStr, `Expected stdout to contain original input`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
104
tests/hooks/pre-bash-reminders.test.js
Normal file
104
tests/hooks/pre-bash-reminders.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Tests for pre-bash-git-push-reminder.js and pre-bash-tmux-reminder.js hooks
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/pre-bash-reminders.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const gitPushScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-git-push-reminder.js');
|
||||||
|
const tmuxScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-tmux-reminder.js');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(scriptPath, command, envOverrides = {}) {
|
||||||
|
const input = { tool_input: { command } };
|
||||||
|
const inputStr = JSON.stringify(input);
|
||||||
|
const result = spawnSync('node', [scriptPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: inputStr,
|
||||||
|
timeout: 10000,
|
||||||
|
env: { ...process.env, ...envOverrides },
|
||||||
|
});
|
||||||
|
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '', inputStr };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing pre-bash-git-push-reminder.js & pre-bash-tmux-reminder.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// --- git-push-reminder tests ---
|
||||||
|
|
||||||
|
console.log(' git-push-reminder:');
|
||||||
|
|
||||||
|
(test('git push triggers stderr warning', () => {
|
||||||
|
const result = runScript(gitPushScript, 'git push origin main');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('Review changes before push'), `Expected stderr to mention review`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('git status has no warning', () => {
|
||||||
|
const result = runScript(gitPushScript, 'git status');
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.strictEqual(result.stderr, '', `Expected no stderr, got: ${result.stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('git push always passes through input on stdout', () => {
|
||||||
|
const result = runScript(gitPushScript, 'git push');
|
||||||
|
assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input');
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
// --- tmux-reminder tests (non-Windows only) ---
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
if (!isWindows) {
|
||||||
|
console.log('\n tmux-reminder:');
|
||||||
|
|
||||||
|
(test('npm install triggers tmux suggestion', () => {
|
||||||
|
const result = runScript(tmuxScript, 'npm install', { TMUX: '' });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('npm test triggers tmux suggestion', () => {
|
||||||
|
const result = runScript(tmuxScript, 'npm test', { TMUX: '' });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('regular command like ls has no tmux suggestion', () => {
|
||||||
|
const result = runScript(tmuxScript, 'ls -la', { TMUX: '' });
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
assert.strictEqual(result.stderr, '', `Expected no stderr for ls, got: ${result.stderr}`);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('tmux reminder always passes through input on stdout', () => {
|
||||||
|
const result = runScript(tmuxScript, 'npm install', { TMUX: '' });
|
||||||
|
assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input');
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
} else {
|
||||||
|
console.log('\n (skipping tmux-reminder tests on Windows)\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
159
tests/hooks/quality-gate.test.js
Normal file
159
tests/hooks/quality-gate.test.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/hooks/quality-gate.js
|
||||||
|
*
|
||||||
|
* Run with: node tests/hooks/quality-gate.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const qualityGate = require('../../scripts/hooks/quality-gate');
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
console.log('\nQuality Gate Hook Tests');
|
||||||
|
console.log('========================\n');
|
||||||
|
|
||||||
|
// --- run() returns original input for valid JSON ---
|
||||||
|
|
||||||
|
console.log('run() pass-through behavior:');
|
||||||
|
|
||||||
|
if (test('returns original input for valid JSON with file_path', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '/tmp/nonexistent-file.js' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input for valid JSON without file_path', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { command: 'ls' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input for valid JSON with nested structure', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '/some/path.ts', content: 'hello' }, other: [1, 2, 3] });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- run() returns original input for invalid JSON ---
|
||||||
|
|
||||||
|
console.log('\nInvalid JSON handling:');
|
||||||
|
|
||||||
|
if (test('returns original input for invalid JSON (no crash)', () => {
|
||||||
|
const input = 'this is not json at all {{{';
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input for partial JSON', () => {
|
||||||
|
const input = '{"tool_input": {';
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input for JSON with trailing garbage', () => {
|
||||||
|
const input = '{"tool_input": {}}extra';
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- run() returns original input when file does not exist ---
|
||||||
|
|
||||||
|
console.log('\nNon-existent file handling:');
|
||||||
|
|
||||||
|
if (test('returns original input when file_path points to non-existent file', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.js' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input when file_path is a non-existent .py file', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.py' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input when file_path is a non-existent .go file', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.go' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- run() returns original input for empty input ---
|
||||||
|
|
||||||
|
console.log('\nEmpty input handling:');
|
||||||
|
|
||||||
|
if (test('returns original input for empty string', () => {
|
||||||
|
const input = '';
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return empty string unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('returns original input for whitespace-only string', () => {
|
||||||
|
const input = ' ';
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return whitespace string unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- run() handles missing tool_input gracefully ---
|
||||||
|
|
||||||
|
console.log('\nMissing tool_input handling:');
|
||||||
|
|
||||||
|
if (test('handles missing tool_input gracefully', () => {
|
||||||
|
const input = JSON.stringify({ something_else: 'value' });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles null tool_input gracefully', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: null });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles tool_input with empty file_path', () => {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: '' } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('handles empty JSON object', () => {
|
||||||
|
const input = JSON.stringify({});
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- run() with a real file (but no formatter installed) ---
|
||||||
|
|
||||||
|
console.log('\nReal file without formatter:');
|
||||||
|
|
||||||
|
if (test('returns original input for existing file with no formatter configured', () => {
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `quality-gate-test-${Date.now()}.js`);
|
||||||
|
fs.writeFileSync(tmpFile, 'const x = 1;\n');
|
||||||
|
try {
|
||||||
|
const input = JSON.stringify({ tool_input: { file_path: tmpFile } });
|
||||||
|
const result = qualityGate.run(input);
|
||||||
|
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmpFile);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Reference in New Issue
Block a user