mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c30e8dd828 |
@@ -0,0 +1,491 @@
|
||||
# Hook Testing Guide
|
||||
|
||||
A comprehensive guide for testing Cline's hooks system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
New hook? Follow this 3-step pattern:
|
||||
|
||||
### Step 1: Create Your Hook Input Builder
|
||||
|
||||
```typescript
|
||||
export function buildMyNewHookInput(params: {
|
||||
someParam: string
|
||||
taskId?: string
|
||||
}): NamedHookInput<"MyNewHook"> {
|
||||
return {
|
||||
taskId: params.taskId || "test-task-id",
|
||||
myNewHook: {
|
||||
someParam: params.someParam
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Write Tests Using Standard Pattern
|
||||
|
||||
```typescript
|
||||
import { setupHookTests, createTestHook, buildPreToolUseInput, assertHookOutput } from './test-utils'
|
||||
import { HookFactory } from '../hook-factory'
|
||||
|
||||
describe("MyNewHook", () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
it("should execute successfully", async () => {
|
||||
await createTestHook(getEnv().tempDir, "MyNewHook", {
|
||||
shouldContinue: true,
|
||||
contextModification: "Hook executed"
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("MyNewHook")
|
||||
const result = await runner.run(buildMyNewHookInput({
|
||||
someParam: "test"
|
||||
}))
|
||||
|
||||
assertHookOutput(result, {
|
||||
shouldContinue: true,
|
||||
contextModification: "Hook executed"
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: Add Integration Tests
|
||||
|
||||
```typescript
|
||||
import { MockHookRunner } from './test-utils'
|
||||
|
||||
it("should call MyNewHook at the right time", async () => {
|
||||
const mockRunner = new MockHookRunner("MyNewHook")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
// Test integration with ToolExecutor or other components
|
||||
// ...
|
||||
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
```
|
||||
|
||||
## Test Utilities Reference
|
||||
|
||||
### setupHookTests()
|
||||
|
||||
Standard test environment setup. Use in every test file:
|
||||
|
||||
```typescript
|
||||
describe("Hook Tests", () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
it("should do something", async () => {
|
||||
const env = getEnv()
|
||||
// env.tempDir is ready to use
|
||||
// env.hooksDirs contains paths to hooks directories
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Creates temporary directory with `.clinerules/hooks` structure
|
||||
- Mocks `StateManager` to return test workspace
|
||||
- Automatically cleans up after each test
|
||||
- Sets up sinon sandbox for stubs
|
||||
|
||||
### createTestHook()
|
||||
|
||||
Creates a test hook with specific behavior:
|
||||
|
||||
```typescript
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "WORKSPACE_RULES: Some rule"
|
||||
}, {
|
||||
delay: 100, // Optional: delay in ms
|
||||
exitCode: 1, // Optional: exit with error
|
||||
malformedJson: true // Optional: output invalid JSON
|
||||
})
|
||||
```
|
||||
|
||||
**Platform handling:**
|
||||
- Unix: Creates executable script with shebang
|
||||
- Windows: Creates `.js` file and `.cmd` wrapper
|
||||
- Handles all platform differences automatically
|
||||
|
||||
### buildPreToolUseInput() / buildPostToolUseInput()
|
||||
|
||||
Builds complete hook input objects:
|
||||
|
||||
```typescript
|
||||
const input = buildPreToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "test.ts", content: "test" },
|
||||
taskId: "custom-task-id" // Optional
|
||||
})
|
||||
|
||||
const input = buildPostToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
result: "File created successfully",
|
||||
success: true,
|
||||
executionTimeMs: 250
|
||||
})
|
||||
```
|
||||
|
||||
### assertHookOutput()
|
||||
|
||||
Validates hook output:
|
||||
|
||||
```typescript
|
||||
assertHookOutput(result, {
|
||||
shouldContinue: true,
|
||||
contextModification: "Expected context"
|
||||
})
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Clear error messages on mismatch
|
||||
- Partial matching (only check fields you care about)
|
||||
- Type-safe
|
||||
|
||||
### MockHookRunner
|
||||
|
||||
For fast integration tests without spawning processes:
|
||||
|
||||
```typescript
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT",
|
||||
errorMessage: ""
|
||||
})
|
||||
|
||||
// Use in your test
|
||||
const result = await mockRunner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
// Assert calls
|
||||
mockRunner.assertCalled(1)
|
||||
mockRunner.assertCalledWith({
|
||||
preToolUse: { toolName: "write_to_file" }
|
||||
})
|
||||
|
||||
// Reset for next test
|
||||
mockRunner.reset()
|
||||
```
|
||||
|
||||
## Using Fixtures
|
||||
|
||||
Fixtures are pre-written hook scripts for common scenarios.
|
||||
|
||||
### When to Use Fixtures
|
||||
|
||||
- Testing real-world hook behavior
|
||||
- Testing complex multi-step scenarios
|
||||
- Creating reusable test cases
|
||||
- Documenting hook patterns
|
||||
|
||||
### How to Use Fixtures
|
||||
|
||||
```typescript
|
||||
import { loadFixture } from './test-utils'
|
||||
|
||||
it("should work with real hook", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await loadFixture("hooks/pretooluse/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
```
|
||||
|
||||
### Available Fixtures
|
||||
|
||||
See [fixtures/README.md](./fixtures/README.md) for the complete list.
|
||||
|
||||
**Common fixtures:**
|
||||
- `hooks/pretooluse/success` - Returns success immediately
|
||||
- `hooks/pretooluse/blocking` - Blocks tool execution
|
||||
- `hooks/pretooluse/context-injection` - Adds context with type prefix
|
||||
- `hooks/pretooluse/error` - Exits with error code
|
||||
- `hooks/pretooluse/timeout` - Times out (for timeout tests)
|
||||
|
||||
## Platform-Specific Testing
|
||||
|
||||
Hooks behave differently on Unix vs Windows:
|
||||
- **Unix**: Uses executable bit (`chmod +x`)
|
||||
- **Windows**: Uses file extensions (`.cmd`, `.bat`, `.exe`)
|
||||
|
||||
### Writing Platform-Specific Tests
|
||||
|
||||
```typescript
|
||||
it("should find executable hook on Unix", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.skip() // Skip on Windows
|
||||
return
|
||||
}
|
||||
|
||||
// Unix-specific test
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log(...)")
|
||||
await fs.chmod(hookPath, 0o755)
|
||||
|
||||
// Test that hook is found and executable
|
||||
})
|
||||
|
||||
it("should find hook with .cmd extension on Windows", async function () {
|
||||
if (process.platform !== "win32") {
|
||||
this.skip() // Skip on Unix
|
||||
return
|
||||
}
|
||||
|
||||
// Windows-specific test
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Tests Simple
|
||||
|
||||
```typescript
|
||||
// GOOD: One assertion per test
|
||||
it("should call PreToolUse before tool execution", async () => {
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
await executor.executeTool({...})
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
|
||||
it("should inject context from PreToolUse", async () => {
|
||||
mockRunner.setResponse({ contextModification: "TEST" })
|
||||
await executor.executeTool({...})
|
||||
executor.taskState.userMessageContent.should.include("TEST")
|
||||
})
|
||||
|
||||
// BAD: Testing multiple things
|
||||
it("should handle hooks correctly", async () => {
|
||||
// Tests calling, context injection, error handling, timing...
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Test Real Execution
|
||||
|
||||
- Use `createTestHook()` for unit tests (real execution)
|
||||
- Use `MockHookRunner` for integration tests (fast execution)
|
||||
- Use fixtures for complex scenarios
|
||||
|
||||
```typescript
|
||||
// Unit test: Real hook execution
|
||||
it("should execute hook and parse output", async () => {
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", { shouldContinue: true })
|
||||
// Test real execution
|
||||
})
|
||||
|
||||
// Integration test: Fast mock
|
||||
it("should integrate with ToolExecutor", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
// Test integration without spawning process
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Maintain Low Complexity
|
||||
|
||||
- Each test function should be < 15 lines
|
||||
- Use helper functions for complex setup
|
||||
- Keep cyclomatic complexity < 5
|
||||
|
||||
```typescript
|
||||
// GOOD: Simple and clear
|
||||
it("should block when shouldContinue is false", async () => {
|
||||
mockRunner.setResponse({ shouldContinue: false })
|
||||
await executor.executeTool({...})
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
|
||||
// BAD: Too complex
|
||||
it("should handle all scenarios", async () => {
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.type === "blocking") {
|
||||
// Complex nested logic...
|
||||
} else if (scenario.type === "success") {
|
||||
// More complex logic...
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Use AAA Pattern
|
||||
|
||||
Arrange, Act, Assert:
|
||||
|
||||
```typescript
|
||||
it("should inject context modification", async () => {
|
||||
// Arrange
|
||||
mockRunner.setResponse({ contextModification: "TEST_CONTEXT" })
|
||||
const executor = createTestExecutor()
|
||||
|
||||
// Act
|
||||
await executor.executeTool({...})
|
||||
|
||||
// Assert
|
||||
executor.taskState.userMessageContent.should.include("TEST_CONTEXT")
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Clear Test Names
|
||||
|
||||
Test name should explain what and why:
|
||||
|
||||
```typescript
|
||||
// GOOD: Explains what and why
|
||||
it("should not call PostToolUse when PreToolUse blocks execution")
|
||||
it("should truncate context modifications larger than 50KB")
|
||||
it("should parse WORKSPACE_RULES prefix from context")
|
||||
|
||||
// BAD: Vague or implementation-focused
|
||||
it("works correctly")
|
||||
it("test hook execution")
|
||||
it("checks the context string")
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Pattern 1: Testing Hook Discovery
|
||||
|
||||
```typescript
|
||||
it("should find hook in workspace", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should not be NoOpRunner
|
||||
runner.constructor.name.should.not.equal("NoOpRunner")
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 2: Testing Hook Execution
|
||||
|
||||
```typescript
|
||||
it("should execute hook and return result", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT"
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({
|
||||
toolName: "write_to_file"
|
||||
}))
|
||||
|
||||
assertHookOutput(result, {
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT"
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 3: Testing Error Handling
|
||||
|
||||
```typescript
|
||||
it("should handle hook errors gracefully", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: false
|
||||
}, { exitCode: 1 })
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 4: Testing Integration
|
||||
|
||||
```typescript
|
||||
it("should call hook at the right time", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
// Stub HookFactory to return mock
|
||||
sinon.stub(HookFactory.prototype, "create").resolves(mockRunner)
|
||||
|
||||
// Execute component logic
|
||||
await component.doSomething()
|
||||
|
||||
// Verify hook was called
|
||||
mockRunner.assertCalled(1)
|
||||
mockRunner.assertCalledWith({
|
||||
preToolUse: { toolName: "expected_tool" }
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Verbose Output
|
||||
|
||||
```bash
|
||||
# Run with debug output
|
||||
DEBUG=cline:hooks npm test
|
||||
|
||||
# Run specific test file
|
||||
npm test -- --grep "PreToolUse"
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue: "Test environment not initialized"**
|
||||
- Cause: Called `getEnv()` outside of test function
|
||||
- Fix: Only call `getEnv()` inside `it()` blocks
|
||||
|
||||
**Issue: "Hook not found"**
|
||||
- Cause: Hook not created or not executable
|
||||
- Fix: Verify `createTestHook()` was called and succeeded
|
||||
|
||||
**Issue: "Expected X calls but got Y"**
|
||||
- Cause: Mock wasn't reset between tests or extra calls
|
||||
- Fix: Use `mockRunner.reset()` in `afterEach()`
|
||||
|
||||
**Issue: Platform-specific test failures**
|
||||
- Cause: Test assumes Unix or Windows behavior
|
||||
- Fix: Add platform check with `this.skip()`
|
||||
|
||||
## Examples from Existing Tests
|
||||
|
||||
See the existing test files for real-world examples:
|
||||
- `hook-factory.test.ts` - Hook discovery and execution
|
||||
- `ToolExecutor.test.ts` - Context injection and integration
|
||||
- `disk.test.ts` - Workspace hook directory discovery
|
||||
|
||||
## Checklist for New Hook Tests
|
||||
|
||||
- [ ] Created input builder function
|
||||
- [ ] Written unit tests with `createTestHook()`
|
||||
- [ ] Added integration tests with `MockHookRunner`
|
||||
- [ ] Tested on both Unix and Windows (if applicable)
|
||||
- [ ] Used `setupHookTests()` for environment
|
||||
- [ ] Kept test functions < 15 lines
|
||||
- [ ] Used clear, descriptive test names
|
||||
- [ ] Added platform-specific skips where needed
|
||||
- [ ] Verified all tests pass
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Fixtures README](./fixtures/README.md) - Guide to using fixture scripts
|
||||
- [Requirements Doc](../../../../HOOKS_TESTING_INFRASTRUCTURE.md) - Original requirements
|
||||
- [Testing Status](../../../../TESTING_STATUS.md) - Implementation progress
|
||||
@@ -0,0 +1,123 @@
|
||||
# Hook Test Fixtures
|
||||
|
||||
This directory contains pre-written hook scripts for testing the Cline hooks system.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
fixtures/
|
||||
├── hooks/
|
||||
│ ├── pretooluse/ # PreToolUse hook fixtures
|
||||
│ │ ├── success/ # Returns success immediately
|
||||
│ │ ├── blocking/ # Blocks tool execution
|
||||
│ │ ├── context-injection/ # Adds context with type prefix
|
||||
│ │ └── error/ # Exits with error code
|
||||
│ ├── posttooluse/ # PostToolUse hook fixtures
|
||||
│ │ ├── success/ # Returns success immediately
|
||||
│ │ └── error/ # Exits with error code
|
||||
│ └── template/ # Template for new hooks (future)
|
||||
└── inputs/ # Sample input data (future)
|
||||
```
|
||||
|
||||
## Using Fixtures in Tests
|
||||
|
||||
### With loadFixture()
|
||||
|
||||
The `loadFixture()` helper function copies a fixture to your test environment:
|
||||
|
||||
```typescript
|
||||
import { loadFixture } from '../test-utils'
|
||||
|
||||
it("should work with real hook", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await loadFixture("hooks/pretooluse/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
```
|
||||
|
||||
### Direct File Copy
|
||||
|
||||
For more control, you can also manually copy fixture files.
|
||||
|
||||
## Available Fixtures
|
||||
|
||||
### PreToolUse Hooks
|
||||
|
||||
#### `hooks/pretooluse/success`
|
||||
- **Returns**: `{ shouldContinue: true, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
|
||||
- **Use for**: Testing happy path scenarios
|
||||
|
||||
#### `hooks/pretooluse/blocking`
|
||||
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
|
||||
- **Use for**: Testing tool execution blocking
|
||||
|
||||
#### `hooks/pretooluse/context-injection`
|
||||
- **Returns**: `{ shouldContinue: true, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
|
||||
- **Use for**: Testing context injection with type prefixes
|
||||
- **Note**: Dynamically includes tool name from input
|
||||
|
||||
#### `hooks/pretooluse/error`
|
||||
- **Behavior**: Prints error to stderr and exits with code 1
|
||||
- **Use for**: Testing error handling
|
||||
|
||||
### PostToolUse Hooks
|
||||
|
||||
#### `hooks/posttooluse/success`
|
||||
- **Returns**: `{ shouldContinue: true, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
|
||||
- **Use for**: Testing PostToolUse execution
|
||||
|
||||
#### `hooks/posttooluse/error`
|
||||
- **Behavior**: Prints error to stderr and exits with code 1
|
||||
- **Use for**: Testing error handling in PostToolUse
|
||||
|
||||
## Platform Considerations
|
||||
|
||||
### Unix (Linux/macOS)
|
||||
- Hooks are executable files without extensions
|
||||
- Must have executable bit set (`chmod +x`)
|
||||
- Include shebang: `#!/usr/bin/env node`
|
||||
|
||||
### Windows
|
||||
- The `loadFixture()` function handles platform differences automatically
|
||||
- Windows fixtures use the same files but permissions are handled differently
|
||||
|
||||
## Creating New Fixtures
|
||||
|
||||
1. Create a new directory under the appropriate hook type
|
||||
2. Add the hook script (executable on Unix)
|
||||
3. Test on both platforms if possible
|
||||
4. Update this README with the new fixture
|
||||
|
||||
### Example: Creating a new fixture
|
||||
|
||||
```bash
|
||||
# Create directory
|
||||
mkdir -p src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario
|
||||
|
||||
# Create hook script
|
||||
cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse << 'EOF'
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "My custom context",
|
||||
errorMessage: ""
|
||||
}));
|
||||
EOF
|
||||
|
||||
# Make executable (Unix)
|
||||
chmod +x src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Keep fixtures simple and focused on one scenario
|
||||
- Test fixtures work on both Unix and Windows
|
||||
- Update this README when adding new fixtures
|
||||
- Remove obsolete fixtures and update references
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.error("PostToolUse hook execution failed");
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "PostToolUse hook executed successfully",
|
||||
errorMessage: ""
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: "Tool execution blocked by hook"
|
||||
}));
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: `WORKSPACE_RULES: Tool ${input.preToolUse.toolName} requires review`,
|
||||
errorMessage: ""
|
||||
}));
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.error("Hook execution failed");
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "PreToolUse hook executed successfully",
|
||||
errorMessage: ""
|
||||
}));
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* TEMPLATE HOOK SCRIPT
|
||||
*
|
||||
* This is a template for creating new Unix hook fixtures.
|
||||
* Copy this file to create a new fixture script.
|
||||
*
|
||||
* Customize the logic below to implement your specific hook behavior.
|
||||
*/
|
||||
|
||||
try {
|
||||
// Parse the input from stdin (what gets passed to the hook)
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// Extract relevant input data
|
||||
// For PreToolUse hooks:
|
||||
const { toolName, parameters } = input.preToolUse || {};
|
||||
// For PostToolUse hooks:
|
||||
// const { toolName, parameters, result, success, executionTimeMs } = input.postToolUse || {};
|
||||
|
||||
// Common metadata (available in all hook types)
|
||||
const { hookName: hookType, timestamp, taskId, workspaceRoots, userId } = input;
|
||||
|
||||
// Initialize output variables
|
||||
let shouldContinue = true;
|
||||
let contextModification = "";
|
||||
let errorMessage = "";
|
||||
|
||||
// === CUSTOMIZE THIS LOGIC ===
|
||||
// Implement your hook logic here
|
||||
|
||||
// Example: Simple success hook
|
||||
contextModification = "TEMPLATE: Hook executed successfully";
|
||||
|
||||
// Example: Context injection based on tool name
|
||||
if (toolName === "write_to_file") {
|
||||
contextModification = "FILE_OPERATIONS: File modification operation";
|
||||
} else if (toolName === "run_command") {
|
||||
contextModification = "SYSTEM_OPERATIONS: Command execution operation";
|
||||
}
|
||||
|
||||
// Example: Validation/bug blocking
|
||||
// if (!parameters?.path) {
|
||||
// shouldContinue = false;
|
||||
// errorMessage = "ERROR: Tool requires a 'path' parameter";
|
||||
// }
|
||||
|
||||
// === END CUSTOM LOGIC ===
|
||||
|
||||
// Return the standardized output format
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue,
|
||||
contextModification,
|
||||
errorMessage
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
// Error handling - hooks should handle their own errors gracefully
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: `HOOK_ERROR: ${errorMessage}`
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
# Hook Template for New Fixtures
|
||||
|
||||
This directory contains templates and examples for creating new hook fixtures. When adding a new hook fixture, copy from these templates and customize as needed.
|
||||
|
||||
## Files in This Template
|
||||
|
||||
- `HookName` - Shell script template (works on all platforms via embedded shell)
|
||||
- `README.md` - This file
|
||||
|
||||
## How to Create a New Fixture
|
||||
|
||||
### Step 1: Choose the Scenario Type
|
||||
|
||||
Decide what your hook fixture should test:
|
||||
- `success` - Returns success immediately
|
||||
- `blocking` - Blocks tool execution
|
||||
- `context-injection` - Adds context information
|
||||
- `error` - Exits with error code
|
||||
|
||||
### Step 2: Create the Directory Structure
|
||||
|
||||
```bash
|
||||
# Example for a new PreToolUse validation fixture
|
||||
mkdir -p src/core/hooks/__tests__/fixtures/hooks/pretooluse/validation/
|
||||
cd src/core/hooks/__tests__/fixtures/hooks/pretooluse/validation/
|
||||
|
||||
# Copy template file as starting point (works on all platforms)
|
||||
cp ../../../template/HookName ./
|
||||
mv HookName PreToolUse # Rename for the specific hook type
|
||||
# Note: No .cmd or .js files needed - embedded shell handles execution
|
||||
```
|
||||
|
||||
### Step 3: Customize the Hook Script
|
||||
|
||||
Edit `PreToolUse` to implement your fixture logic:
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Parse the input from stdin (what gets passed to the hook)
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// Example: Validate that tool parameters exist
|
||||
const { toolName, parameters } = input.preToolUse;
|
||||
|
||||
let shouldContinue = true;
|
||||
let contextModification = "";
|
||||
let errorMessage = "";
|
||||
|
||||
// Your validation logic here
|
||||
if (!parameters || !parameters.path) {
|
||||
shouldContinue = false;
|
||||
errorMessage = "ERROR: Tool requires a 'path' parameter";
|
||||
} else {
|
||||
contextModification = "VALIDATION: Basic input validation passed";
|
||||
}
|
||||
|
||||
// Return the standardized output
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue,
|
||||
contextModification,
|
||||
errorMessage
|
||||
}));
|
||||
```
|
||||
|
||||
### Step 4: Make the Script Executable (Unix/macOS/Linux)
|
||||
|
||||
```bash
|
||||
# Make the hook executable (on Unix/macOS/Linux)
|
||||
chmod +x PreToolUse
|
||||
|
||||
# On Windows, the embedded shell handles execution automatically
|
||||
```
|
||||
|
||||
### Step 5: Test Your Fixture
|
||||
|
||||
```javascript
|
||||
// In your test file:
|
||||
await createTestHook(tempDir, "PreToolUse", {
|
||||
shouldContinue: false,
|
||||
errorMessage: "ERROR: Tool requires a 'path' parameter"
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
parameters: {} // Missing path parameter
|
||||
}))
|
||||
|
||||
result.shouldContinue.should.be.false()
|
||||
result.errorMessage.should.equal("ERROR: Tool requires a 'path' parameter")
|
||||
```
|
||||
|
||||
### Step 6: Update Documentation
|
||||
|
||||
Add your new fixture to all relevant documentation:
|
||||
|
||||
1. Update `fixtures/README.md` with your new fixture
|
||||
2. Update `TESTING_GUIDE.md` if introducing new patterns
|
||||
3. Add examples to relevant test files
|
||||
|
||||
## Template Hook Patterns
|
||||
|
||||
### Input Validation Template
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// Validate required fields exist
|
||||
const { toolName, parameters } = input.hookType; // preToolUse/postToolUse
|
||||
|
||||
if (!parameters?.requiredField) {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: `ERROR: Missing required field 'requiredField'`
|
||||
}));
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "VALIDATION: Input validation passed",
|
||||
errorMessage: ""
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Context Injection Template
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// Add context based on input analysis
|
||||
const { toolName, parameters } = input.hookType;
|
||||
|
||||
let contextType = "GENERAL";
|
||||
let context = "Basic tool usage";
|
||||
|
||||
// Analyze and add specific context
|
||||
if (toolName === "write_to_file") {
|
||||
contextType = "FILE_OPERATIONS";
|
||||
context = `Creating or editing file: ${parameters?.path || 'unknown'}`;
|
||||
} else if (toolName === "run_command") {
|
||||
contextType = "SYSTEM_OPERATIONS";
|
||||
context = `Running system command: ${parameters?.command?.substring(0, 20) || 'unknown'}`;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: `${contextType}: ${context}`,
|
||||
errorMessage: ""
|
||||
}));
|
||||
```
|
||||
|
||||
### Permissions/Blocking Template
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
const { toolName, parameters } = input.hookType;
|
||||
const sensitivePaths = ['/etc', '/var', 'C:\\Windows'];
|
||||
|
||||
// Check for security violations
|
||||
const path = parameters?.path || parameters?.destination;
|
||||
const isSensitivePath = sensitivePaths.some(sensitive =>
|
||||
path?.startsWith(sensitive)
|
||||
);
|
||||
|
||||
if (isSensitivePath) {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: `SECURITY: Access to sensitive path '${path}' is blocked`
|
||||
}));
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "SECURITY: Path access approved",
|
||||
errorMessage: ""
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Error Simulation Template
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// Simulate error exit
|
||||
console.error("Hook execution failed");
|
||||
process.exit(1);
|
||||
```
|
||||
|
||||
## Variable Naming Convention
|
||||
|
||||
### Input Variables
|
||||
```javascript
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
|
||||
// PreToolUse hooks
|
||||
const { toolName, parameters } = input.preToolUse;
|
||||
|
||||
// PostToolUse hooks
|
||||
const { toolName, parameters, result, success, executionTimeMs } = input.postToolUse;
|
||||
|
||||
// Common metadata
|
||||
const { hookName, timestamp, taskId, workspaceRoots, userId } = input;
|
||||
```
|
||||
|
||||
### Output Variables
|
||||
```javascript
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: boolean, // Allow/deny execution
|
||||
contextModification: string, // Context for future AI decisions (optional)
|
||||
errorMessage: string // Error description on blocking (optional)
|
||||
}));
|
||||
```
|
||||
|
||||
## Best Practices for New Fixtures
|
||||
|
||||
### Keep Fixtures Focused
|
||||
- **One purpose per fixture**: Test one specific scenario
|
||||
- **Simple logic**: Easy to understand and debug
|
||||
- **Document thoroughly**: Comment complex logic
|
||||
|
||||
### Make Fixtures Platform-Neutral
|
||||
- Written in Node.js, works on all platforms
|
||||
- Platform-specific logic abstracted away
|
||||
- Test fixtures on both Unix and Windows
|
||||
|
||||
### Include Errors and Edge Cases
|
||||
- **Error fixtures**: Test error handling paths
|
||||
- **Edge cases**: Missing inputs, malformed data
|
||||
|
||||
### Consistent Naming
|
||||
- Use UPPERCASE for context type prefixes
|
||||
- Be descriptive about what the fixture tests
|
||||
- Follow existing naming patterns
|
||||
|
||||
## Examples from Existing Fixtures
|
||||
|
||||
See the existing fixtures in parent directories for real examples:
|
||||
- `../success/` - Simple success case
|
||||
- `../blocking/` - How to block execution
|
||||
- `../context-injection/` - How to inject context
|
||||
- `../error/` - How to return errors
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. **Copy an existing fixture** as starting point
|
||||
2. **Look at template patterns** in this README
|
||||
3. **Check TESTING_GUIDE.md** for usage examples
|
||||
4. **Test on both platforms** before submitting
|
||||
5. **Add documentation** for the new fixture
|
||||
@@ -0,0 +1,935 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { promisify } from "util"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { setupHookTests } from "./setup"
|
||||
import { buildPostToolUseInput, buildPreToolUseInput, createTestHook } from "./test-utils"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Error Scenario Testing for Hook System
|
||||
*
|
||||
* Comprehensive error scenario coverage including:
|
||||
* - Resource management and leak prevention
|
||||
* - Process lifecycle and cleanup
|
||||
* - Multi-root workspace concurrency
|
||||
* - Input validation edge cases
|
||||
* - Embedded shell failures
|
||||
* - User cancellation (pending cancel API)
|
||||
* - Catastrophic failure prevention
|
||||
*/
|
||||
describe("Hook System - Error Scenarios", () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
// Skip hook execution tests on Windows (hooks not yet supported on Windows)
|
||||
before(function () {
|
||||
if (process.platform === "win32") {
|
||||
this.skip()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper: Get current process count for leak detection.
|
||||
* Returns -1 if process counting is unavailable (which will skip process leak assertions).
|
||||
*/
|
||||
async function getProcessCount(): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await execAsync("ps aux | grep -c '[n]ode'")
|
||||
return parseInt(stdout.trim())
|
||||
} catch {
|
||||
// If process counting fails, return -1 to skip the assertion
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Wait for a specified duration
|
||||
*/
|
||||
async function waitFor(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Creates a multi-root workspace environment for testing
|
||||
*/
|
||||
async function createMultiRootWorkspace(rootNames: string[]): Promise<string[]> {
|
||||
const rootPaths = await Promise.all(
|
||||
rootNames.map(async (name) => {
|
||||
const rootPath = path.join(getEnv().tempDir, name)
|
||||
await fs.mkdir(rootPath, { recursive: true })
|
||||
return rootPath
|
||||
}),
|
||||
)
|
||||
|
||||
// Update the existing StateManager stub (already created by setupHookTests)
|
||||
const stateManagerStub = StateManager.get as sinon.SinonStub
|
||||
stateManagerStub.returns({
|
||||
getGlobalStateKey: () => rootPaths.map((path) => ({ path })),
|
||||
} as any)
|
||||
|
||||
return rootPaths
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Asserts process count hasn't grown significantly (no process leak)
|
||||
*/
|
||||
function assertNoProcessLeak(initialCount: number, finalCount: number): void {
|
||||
if (initialCount === -1 || finalCount === -1) {
|
||||
// Process counting unavailable, skip assertion
|
||||
return
|
||||
}
|
||||
// Allow some variance due to unrelated system processes
|
||||
const processDiff = finalCount - initialCount
|
||||
processDiff.should.be.lessThan(3) // Allow up to 2 processes variance
|
||||
processDiff.should.be.greaterThan(-3) // Allow processes to decrease
|
||||
}
|
||||
|
||||
describe("Resource Management", () => {
|
||||
it("should handle hooks that attempt excessive memory allocation", async function () {
|
||||
this.timeout(35000)
|
||||
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{},
|
||||
{
|
||||
customNodeCode: `
|
||||
// Try to allocate excessive memory
|
||||
try {
|
||||
const arrays = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
arrays.push(new Array(1000000).fill('x'));
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: ""
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: "Out of memory: " + error.message
|
||||
}));
|
||||
}
|
||||
`,
|
||||
},
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Hook should either succeed with limited memory or fail gracefully
|
||||
try {
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
// If it succeeds, verify it's a valid result
|
||||
result.should.have.property("shouldContinue")
|
||||
} catch (error: any) {
|
||||
// If it fails, should be a controlled failure, not a system crash
|
||||
error.message.should.be.a.String()
|
||||
}
|
||||
})
|
||||
|
||||
it("should cleanup file descriptors after hook errors", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
// Get initial FD count (if available)
|
||||
let initialFdCount = -1
|
||||
try {
|
||||
if (process.platform !== "win32") {
|
||||
const { stdout } = await execAsync(`lsof -p ${process.pid} | wc -l`)
|
||||
initialFdCount = parseInt(stdout.trim())
|
||||
}
|
||||
} catch {
|
||||
// FD counting not available, skip this part of the test
|
||||
}
|
||||
|
||||
// Create a hook that exits with error
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: "",
|
||||
},
|
||||
{ exitCode: 1 },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Run hook multiple times with errors
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
}
|
||||
|
||||
// Verify FD count hasn't grown significantly
|
||||
if (initialFdCount !== -1) {
|
||||
await waitFor(500) // Allow cleanup time
|
||||
const { stdout } = await execAsync(`lsof -p ${process.pid} | wc -l`)
|
||||
const finalFdCount = parseInt(stdout.trim())
|
||||
|
||||
// Allow some growth, but not proportional to number of failed hooks
|
||||
const fdGrowth = finalFdCount - initialFdCount
|
||||
fdGrowth.should.be.lessThan(20) // Should not leak FDs
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle rapid sequential hook executions", async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "RAPID_TEST: Hook executed",
|
||||
errorMessage: "",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Execute hook 10 times rapidly
|
||||
const results = await Promise.all(
|
||||
Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
runner.run(
|
||||
buildPreToolUseInput({
|
||||
toolName: `test_${i}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// All should succeed
|
||||
results.should.have.length(10)
|
||||
results.forEach((result) => {
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Process Lifecycle", () => {
|
||||
it("should cleanup processes after hook crashes", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const initialProcessCount = await getProcessCount()
|
||||
if (initialProcessCount === -1) {
|
||||
this.skip() // Skip if process counting unavailable
|
||||
return
|
||||
}
|
||||
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{ shouldContinue: true },
|
||||
{
|
||||
customNodeCode: "process.exit(1);",
|
||||
},
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Run hook that crashes
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// Verify no process leak
|
||||
await waitFor(1000) // Allow cleanup time
|
||||
const finalProcessCount = await getProcessCount()
|
||||
|
||||
assertNoProcessLeak(initialProcessCount, finalProcessCount)
|
||||
})
|
||||
|
||||
it("should handle hooks that spawn child processes", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{},
|
||||
{
|
||||
customNodeCode: `const { spawn } = require('child_process');
|
||||
const child = spawn('node', ['-e', 'setTimeout(() => {}, 100)']);
|
||||
child.on('close', () => {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "CHILD_PROCESS: Spawned and cleaned up",
|
||||
errorMessage: ""
|
||||
}));
|
||||
});`,
|
||||
},
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should handle child processes properly
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/CHILD_PROCESS/)
|
||||
})
|
||||
|
||||
it("should handle hook process that exits without output", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {}, { exitWithoutOutput: true })
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should handle missing output gracefully
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multi-Root Workspace Concurrency", () => {
|
||||
it("should execute hooks from multiple workspace roots concurrently", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2, root3] = await createMultiRootWorkspace(["root1", "root2", "root3"])
|
||||
|
||||
// Create hooks in each root with different delays
|
||||
await createTestHook(
|
||||
root1,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT1: Hook executed",
|
||||
},
|
||||
{ delay: 100 },
|
||||
)
|
||||
|
||||
await createTestHook(
|
||||
root2,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT2: Hook executed",
|
||||
},
|
||||
{ delay: 200 },
|
||||
)
|
||||
|
||||
await createTestHook(
|
||||
root3,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT3: Hook executed",
|
||||
},
|
||||
{ delay: 150 },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const start = Date.now()
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// Should run concurrently (not sequentially)
|
||||
// Sequential would be 100 + 200 + 150 = 450ms minimum
|
||||
// Concurrent should be closer to max(100, 200, 150) = 200ms
|
||||
// But allow generous overhead for CI/slower systems
|
||||
elapsed.should.be.lessThan(2000) // Must complete before sequential time
|
||||
|
||||
// Should aggregate all contexts
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/ROOT1/)
|
||||
result.contextModification!.should.match(/ROOT2/)
|
||||
result.contextModification!.should.match(/ROOT3/)
|
||||
})
|
||||
|
||||
it("should handle one root's hook failing while others succeed", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2, root3] = await createMultiRootWorkspace(["root1", "root2", "root3"])
|
||||
|
||||
// Root 1: Success
|
||||
await createTestHook(root1, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT1: Success",
|
||||
})
|
||||
|
||||
// Root 2: Blocks execution
|
||||
await createTestHook(root2, "PreToolUse", {
|
||||
shouldContinue: false,
|
||||
errorMessage: "ROOT2: Blocked by validation",
|
||||
})
|
||||
|
||||
// Root 3: Success
|
||||
await createTestHook(root3, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT3: Success",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
// If any hook blocks, overall should block
|
||||
result.shouldContinue.should.be.false()
|
||||
|
||||
// Should collect both successful contexts and error
|
||||
result.contextModification!.should.match(/ROOT1/)
|
||||
result.contextModification!.should.match(/ROOT3/)
|
||||
result.errorMessage!.should.match(/ROOT2: Blocked/)
|
||||
})
|
||||
|
||||
it("should aggregate results from all workspace hooks", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2] = await createMultiRootWorkspace(["root1", "root2"])
|
||||
|
||||
await createTestHook(root1, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "WORKSPACE_RULES: Root 1 conventions",
|
||||
})
|
||||
|
||||
await createTestHook(root2, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "FILE_OPERATIONS: Root 2 validation",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "write_to_file" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
|
||||
// Context should be aggregated with separation
|
||||
const contexts = result.contextModification!.split("\n\n")
|
||||
contexts.should.have.length(2)
|
||||
contexts[0].should.match(/Root 1 conventions/)
|
||||
contexts[1].should.match(/Root 2 validation/)
|
||||
})
|
||||
|
||||
it("should handle slow hook in one root not blocking fast hooks", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2, root3] = await createMultiRootWorkspace(["root1", "root2", "root3"])
|
||||
|
||||
// Root 1: Slow (5 seconds)
|
||||
await createTestHook(
|
||||
root1,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT1: Slow hook",
|
||||
},
|
||||
{ delay: 5000 },
|
||||
)
|
||||
|
||||
// Root 2: Fast
|
||||
await createTestHook(root2, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT2: Fast hook",
|
||||
})
|
||||
|
||||
// Root 3: Fast
|
||||
await createTestHook(root3, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT3: Fast hook",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const start = Date.now()
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// Should take ~5s (waiting for slow hook), not 5s * 3 sequentially
|
||||
elapsed.should.be.greaterThan(4900)
|
||||
elapsed.should.be.lessThan(6000)
|
||||
|
||||
// All results should be aggregated
|
||||
result.contextModification!.should.match(/ROOT1/)
|
||||
result.contextModification!.should.match(/ROOT2/)
|
||||
result.contextModification!.should.match(/ROOT3/)
|
||||
})
|
||||
|
||||
it("should handle PostToolUse across multiple roots", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2] = await createMultiRootWorkspace(["root1", "root2"])
|
||||
|
||||
await createTestHook(root1, "PostToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT1: Logged operation",
|
||||
})
|
||||
|
||||
await createTestHook(root2, "PostToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT2: Updated metrics",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PostToolUse")
|
||||
|
||||
const result = await runner.run(
|
||||
buildPostToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
result: "File created",
|
||||
success: true,
|
||||
executionTimeMs: 250,
|
||||
}),
|
||||
)
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/ROOT1/)
|
||||
result.contextModification!.should.match(/ROOT2/)
|
||||
})
|
||||
|
||||
it("should handle mixed success/failure in multi-root execution", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
const [root1, root2, root3] = await createMultiRootWorkspace(["root1", "root2", "root3"])
|
||||
|
||||
// Root 1: Success
|
||||
await createTestHook(root1, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT1: Validated",
|
||||
})
|
||||
|
||||
// Root 2: Failure (exit with error)
|
||||
await createTestHook(
|
||||
root2,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: false,
|
||||
errorMessage: "ROOT2: Error",
|
||||
},
|
||||
{ exitCode: 1 },
|
||||
)
|
||||
|
||||
// Root 3: Success
|
||||
await createTestHook(root3, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "ROOT3: Validated",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should handle partial failure gracefully
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
// If root2 exits with error, the whole thing should fail
|
||||
throw new Error("Should have failed")
|
||||
} catch (error: any) {
|
||||
// Expected - one hook failed
|
||||
error.message.should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Input Validation", () => {
|
||||
it("should handle reasonably sized input gracefully", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "INPUT_TEST: Processed",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Create reasonably sized parameters object
|
||||
const params = {
|
||||
content: "x".repeat(10000), // 10KB of content
|
||||
metadata: Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
id: i,
|
||||
data: "y".repeat(100),
|
||||
})),
|
||||
}
|
||||
|
||||
// Should handle normal-sized input
|
||||
const result = await runner.run(
|
||||
buildPreToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
parameters: params,
|
||||
}),
|
||||
)
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/INPUT_TEST/)
|
||||
})
|
||||
|
||||
it("should handle parameters with special characters", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{},
|
||||
{
|
||||
customNodeCode: `const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const path = input.preToolUse.parameters.path || '';
|
||||
const hasSpecialChars = /[<>:"|?*\\x00-\\x1f]/.test(path);
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: hasSpecialChars ? "VALIDATION: Special chars detected" : "VALIDATION: Normal path",
|
||||
errorMessage: ""
|
||||
}));`,
|
||||
},
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Test with special characters
|
||||
const result = await runner.run(
|
||||
buildPreToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
parameters: {
|
||||
path: 'file with "quotes" and <brackets> and |pipes|',
|
||||
content: "test",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/Special chars/)
|
||||
})
|
||||
|
||||
it("should handle undefined and null parameters", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{},
|
||||
{
|
||||
customNodeCode: `const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const params = input.preToolUse.parameters;
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "PARAMS: " + JSON.stringify(params),
|
||||
errorMessage: ""
|
||||
}));`,
|
||||
},
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Test with minimal parameters
|
||||
const result = await runner.run(
|
||||
buildPreToolUseInput({
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
}),
|
||||
)
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/PARAMS/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Embedded Shell Failures", () => {
|
||||
it("should provide helpful error when hook script not found", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
// Don't create any hooks
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should gracefully handle missing hook (NoOpRunner)
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("User Cancellation", () => {
|
||||
it.skip("should cancel long-running hook on user request", async function () {
|
||||
// TODO: Implement when cancel button/API is added
|
||||
// Current implementation uses 30s timeout, not user cancellation
|
||||
this.timeout(35000)
|
||||
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks")
|
||||
const scriptContent = `#!/usr/bin/env node
|
||||
// Infinite loop - should be cancellable by user
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: ""
|
||||
}));
|
||||
}, 999999999);
|
||||
`
|
||||
const scriptPath = path.join(hookPath, "PreToolUse")
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// When cancel API is implemented:
|
||||
// const promise = runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
// setTimeout(() => runner.cancel(), 1000)
|
||||
// await promise should reject with cancellation error
|
||||
})
|
||||
|
||||
it.skip("should cleanup cancelled processes", async () => {
|
||||
// TODO: Implement when cancel API is added
|
||||
// Verify no zombie processes after user cancellation
|
||||
})
|
||||
|
||||
it.skip("should handle cancellation of already-completed hooks", async () => {
|
||||
// TODO: Implement when cancel API is added
|
||||
// Cancelling after completion should be a no-op
|
||||
})
|
||||
|
||||
it.skip("should handle multiple consecutive cancellations", async () => {
|
||||
// TODO: Implement when cancel API is added
|
||||
// Ensure cancellation doesn't break subsequent hook calls
|
||||
})
|
||||
|
||||
it.skip("should cancel all workspace hooks when user cancels", async () => {
|
||||
// TODO: Implement when cancel API is added
|
||||
// Should cancel all concurrent hook processes across all roots
|
||||
})
|
||||
})
|
||||
|
||||
describe("Catastrophic Failure Prevention", () => {
|
||||
it("should never leak processes after errors", async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
const initialProcessCount = await getProcessCount()
|
||||
if (initialProcessCount === -1) {
|
||||
this.skip() // Skip if process counting unavailable
|
||||
return
|
||||
}
|
||||
|
||||
// Create various failing hooks
|
||||
const scenarios = [
|
||||
{ exitCode: 1 }, // Exit with error
|
||||
{ malformedJson: true }, // Invalid JSON
|
||||
{ delay: 100, exitCode: 1 }, // Delayed failure
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", { shouldContinue: true }, scenario)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// Clean up for next iteration
|
||||
await fs.unlink(path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")).catch(() => {})
|
||||
}
|
||||
|
||||
// Verify no cumulative process leaks
|
||||
await waitFor(1000)
|
||||
const finalProcessCount = await getProcessCount()
|
||||
|
||||
assertNoProcessLeak(initialProcessCount, finalProcessCount)
|
||||
})
|
||||
|
||||
it("should never corrupt task state on hook failure", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
// Create a failing hook
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: false,
|
||||
errorMessage: "Hook failed",
|
||||
},
|
||||
{ exitCode: 1 },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// First hook call fails
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test1" }))
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Replace with working hook
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "RECOVERY: Working now",
|
||||
})
|
||||
|
||||
// Second call should work (verifies no state corruption)
|
||||
const factory2 = new HookFactory()
|
||||
const runner2 = await factory2.create("PreToolUse")
|
||||
const result = await runner2.run(buildPreToolUseInput({ toolName: "test2" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/RECOVERY/)
|
||||
})
|
||||
|
||||
it("should handle hooks with infinite loops via timeout", async function () {
|
||||
this.timeout(35000)
|
||||
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks")
|
||||
const scriptContent = `#!/usr/bin/env node
|
||||
// Infinite loop (will be stopped by timeout)
|
||||
while(true) {
|
||||
// Spin forever
|
||||
}
|
||||
`
|
||||
const scriptPath = path.join(hookPath, "PreToolUse")
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
throw new Error("Should have timed out")
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// Should timeout around 30s
|
||||
elapsed.should.be.greaterThan(29000)
|
||||
elapsed.should.be.lessThan(35000)
|
||||
error.message.should.match(/timed out/)
|
||||
}
|
||||
})
|
||||
|
||||
it("should execute hooks asynchronously without blocking", async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
// Create a slow hook
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "ASYNC: Completed",
|
||||
},
|
||||
{ delay: 2000 },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
// Start hook execution (should not block)
|
||||
const promise = runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
// Verify we can do other work while hook runs
|
||||
let workDone = false
|
||||
setTimeout(() => {
|
||||
workDone = true
|
||||
}, 100)
|
||||
|
||||
await promise
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// Hook took ~2s but we could do work during that time
|
||||
elapsed.should.be.greaterThan(1900)
|
||||
workDone.should.be.true()
|
||||
})
|
||||
|
||||
it("should recover from any hook failure type", async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
const failureTypes = [
|
||||
{ desc: "exit code", options: { exitCode: 1 } },
|
||||
{ desc: "malformed JSON", options: { malformedJson: true } },
|
||||
{
|
||||
desc: "exception",
|
||||
script: `#!/usr/bin/env node
|
||||
throw new Error("Intentional error");
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
for (const failure of failureTypes) {
|
||||
if (failure.script) {
|
||||
const scriptPath = path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
await fs.writeFile(scriptPath, failure.script)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
} else {
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", { shouldContinue: true }, failure.options!)
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Hook should fail
|
||||
try {
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
} catch (error: any) {
|
||||
// Expected failure
|
||||
error.should.be.instanceof(Error)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")).catch(() => {})
|
||||
|
||||
// Next tool use should work
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: `RECOVERY: After ${failure.desc} failure`,
|
||||
})
|
||||
|
||||
const factory2 = new HookFactory()
|
||||
const runner2 = await factory2.create("PreToolUse")
|
||||
const result = await runner2.run(buildPreToolUseInput({ toolName: "test" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.match(/RECOVERY/)
|
||||
|
||||
// Clean up for next iteration
|
||||
await fs.unlink(path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")).catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,61 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { setupHookTests } from "./setup"
|
||||
import { assertHookOutput, buildPostToolUseInput, buildPreToolUseInput, createTestHook } from "./test-utils"
|
||||
|
||||
describe("Hook System", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
// Helper to get platform-appropriate hook filename
|
||||
const getHookFilename = (hookName: string): string => {
|
||||
return process.platform === "win32" ? `${hookName}.cmd` : hookName
|
||||
}
|
||||
|
||||
// Helper to write hook script with platform-specific wrapper
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
// Skip hook execution tests on Windows (hooks not yet supported on Windows)
|
||||
before(function () {
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, create both a .js file and a .cmd wrapper
|
||||
// This avoids command line length limits and complex escaping issues
|
||||
const jsPath = hookPath.replace(/\.cmd$/, ".js")
|
||||
await fs.writeFile(jsPath, nodeScript)
|
||||
|
||||
// Create .cmd wrapper that calls the .js file
|
||||
const batchScript = `@echo off
|
||||
node "%~dp0${path.basename(jsPath)}"`
|
||||
await fs.writeFile(hookPath, batchScript)
|
||||
} else {
|
||||
// On Unix, write the script directly with shebang
|
||||
await fs.writeFile(hookPath, nodeScript)
|
||||
await fs.chmod(hookPath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tempDir = path.join(os.tmpdir(), `hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create .clinerules/hooks directory
|
||||
const hooksDir = path.join(tempDir, ".clinerules", "hooks")
|
||||
await fs.mkdir(hooksDir, { recursive: true })
|
||||
|
||||
// Mock StateManager to return our temp directory
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: tempDir }],
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
this.skip()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,13 +21,7 @@ node "%~dp0${path.basename(jsPath)}"`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
@@ -79,103 +30,74 @@ node "%~dp0${path.basename(jsPath)}"`
|
||||
|
||||
describe("StdioHookRunner", () => {
|
||||
it("should execute hook script and parse output", async () => {
|
||||
// Create a test hook script
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = require('fs').readFileSync(0, 'utf-8');
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT: Added by hook"
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
|
||||
// Test execution
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT: Added by hook",
|
||||
errorMessage: "",
|
||||
})
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.equal("TEST_CONTEXT: Added by hook")
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
assertHookOutput(result, {
|
||||
shouldContinue: true,
|
||||
contextModification: "TEST_CONTEXT: Added by hook",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle script that blocks execution", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: false,
|
||||
errorMessage: "Hook blocked execution"
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: false,
|
||||
contextModification: "",
|
||||
errorMessage: "Hook blocked execution",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
assertHookOutput(result, {
|
||||
shouldContinue: false,
|
||||
errorMessage: "Hook blocked execution",
|
||||
})
|
||||
|
||||
result.shouldContinue.should.be.false()
|
||||
result.errorMessage!.should.equal("Hook blocked execution")
|
||||
})
|
||||
|
||||
it("should truncate large context modifications", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
// Create context larger than 50KB
|
||||
const largeContext = "x".repeat(60000)
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "${largeContext}"
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
const MAX_CONTEXT_SIZE = 50 * 1024 // 50KB
|
||||
const largeContext = "x".repeat(MAX_CONTEXT_SIZE + 10000)
|
||||
await createTestHook(getEnv().tempDir, "PreToolUse", {
|
||||
shouldContinue: true,
|
||||
contextModification: largeContext,
|
||||
errorMessage: "",
|
||||
})
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
|
||||
result.contextModification!.length.should.be.lessThan(60000)
|
||||
result.contextModification!.length.should.be.lessThan(largeContext.length)
|
||||
result.contextModification!.should.match(/truncated due to size limit/)
|
||||
})
|
||||
|
||||
it("should handle script errors", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
process.exit(1)`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: "",
|
||||
},
|
||||
{ exitCode: 1 },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
@@ -183,23 +105,22 @@ process.exit(1)`
|
||||
})
|
||||
|
||||
it("should handle malformed JSON output", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log("not valid json")`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
await createTestHook(
|
||||
getEnv().tempDir,
|
||||
"PreToolUse",
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: "",
|
||||
},
|
||||
{ malformedJson: true },
|
||||
)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
@@ -207,26 +128,28 @@ console.log("not valid json")`
|
||||
})
|
||||
|
||||
it("should pass hook input via stdin", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
// Create a custom hook that echoes the tool name
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks")
|
||||
const scriptContent = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "Received tool: " + input.preToolUse.toolName
|
||||
contextModification: "Received tool: " + input.preToolUse.toolName,
|
||||
errorMessage: ""
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
// Create single shell script (works on all platforms via embedded shell)
|
||||
const scriptPath = path.join(hookPath, "PreToolUse")
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "my_test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "my_test_tool" }))
|
||||
|
||||
result.contextModification!.should.equal("Received tool: my_test_tool")
|
||||
})
|
||||
@@ -234,29 +157,35 @@ console.log(JSON.stringify({
|
||||
|
||||
describe("PostToolUse Hook", () => {
|
||||
it("should receive execution results", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PostToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
// Create a custom hook that echoes the success status
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks")
|
||||
const scriptContent = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "Tool succeeded: " + input.postToolUse.success
|
||||
contextModification: "Tool succeeded: " + input.postToolUse.success,
|
||||
errorMessage: ""
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
// Create single shell script (works on all platforms via embedded shell)
|
||||
const scriptPath = path.join(hookPath, "PostToolUse")
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PostToolUse")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
postToolUse: {
|
||||
const result = await runner.run(
|
||||
buildPostToolUseInput({
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
result: "success",
|
||||
success: true,
|
||||
executionTimeMs: 100,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
result.contextModification!.should.equal("Tool succeeded: true")
|
||||
})
|
||||
@@ -269,24 +198,16 @@ console.log(JSON.stringify({
|
||||
return
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({ shouldContinue: true }))`
|
||||
console.log(JSON.stringify({ shouldContinue: true, contextModification: "", errorMessage: "" }))`
|
||||
|
||||
await fs.writeFile(hookPath, hookScript)
|
||||
await fs.chmod(hookPath, 0o755)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should find and execute the hook
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
@@ -297,28 +218,19 @@ console.log(JSON.stringify({ shouldContinue: true }))`
|
||||
return
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({ shouldContinue: true }))`
|
||||
console.log(JSON.stringify({ shouldContinue: true, contextModification: "", errorMessage: "" }))`
|
||||
|
||||
// Write but don't make executable
|
||||
await fs.writeFile(hookPath, hookScript)
|
||||
// Explicitly remove executable permission
|
||||
await fs.chmod(hookPath, 0o644)
|
||||
await fs.chmod(hookPath, 0o644) // Explicitly remove executable permission
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
// Should return NoOpRunner
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
|
||||
// NoOpRunner always returns success
|
||||
// Should return NoOpRunner which always returns success
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
|
||||
@@ -326,15 +238,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
|
||||
// No hook file created
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should return NoOpRunner
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
@@ -345,42 +249,41 @@ console.log(JSON.stringify({ shouldContinue: true }))`
|
||||
// No hook file exists - ENOENT is expected
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
// Should not throw, returns NoOpRunner
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle hook input with all parameters", async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", getHookFilename("PreToolUse"))
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
// Create a hook that validates all input fields are present
|
||||
const hookPath = path.join(getEnv().tempDir, ".clinerules", "hooks")
|
||||
const scriptContent = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
|
||||
input.taskId && input.workspaceRoots !== undefined;
|
||||
console.log(JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields"
|
||||
contextModification: hasAllFields ? "All fields present" : "Missing fields",
|
||||
errorMessage: ""
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
// Create single shell script (works on all platforms via embedded shell)
|
||||
const scriptPath = path.join(hookPath, "PreToolUse")
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
try {
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
} catch (error) {
|
||||
// Ignore chmod errors on Windows
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
const result = await runner.run(
|
||||
buildPreToolUseInput({
|
||||
toolName: "test_tool",
|
||||
parameters: { key: "value" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
result.contextModification!.should.equal("All fields present")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { createHooksDirectory } from "./test-utils"
|
||||
|
||||
/**
|
||||
* Test environment containing temp directories and cleanup functions.
|
||||
*/
|
||||
export interface HookTestEnvironment {
|
||||
/** Temporary directory for this test */
|
||||
tempDir: string
|
||||
/** Array of hooks directories (.clinerules/hooks paths) */
|
||||
hooksDirs: string[]
|
||||
/** Cleanup function to remove temp directories */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh test environment with temp directories.
|
||||
* Automatically creates .clinerules/hooks structure.
|
||||
*
|
||||
* @returns Test environment with cleanup function
|
||||
*
|
||||
* @example
|
||||
* const env = await createHookTestEnvironment()
|
||||
* // Use env.tempDir, env.hooksDirs in tests
|
||||
* await env.cleanup() // Clean up after tests
|
||||
*/
|
||||
export async function createHookTestEnvironment(): Promise<HookTestEnvironment> {
|
||||
const tempDir = path.join(os.tmpdir(), `hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
const hooksDir = await createHooksDirectory(tempDir)
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
hooksDirs: [hooksDir],
|
||||
cleanup: async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error: any) {
|
||||
// Only ignore ENOENT (already deleted), log other errors
|
||||
if (error.code !== "ENOENT") {
|
||||
console.warn(`Cleanup warning for ${tempDir}:`, error.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard setup for hook tests. Returns accessor to environment.
|
||||
* Use in describe() blocks for automatic setup/teardown.
|
||||
*
|
||||
* @returns Object with getEnv() method to access test environment
|
||||
*
|
||||
* @example
|
||||
* describe("My Hook Tests", () => {
|
||||
* const { getEnv } = setupHookTests()
|
||||
*
|
||||
* it("should do something", async () => {
|
||||
* const env = getEnv()
|
||||
* // env.tempDir is ready to use
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function setupHookTests(): {
|
||||
getEnv: () => HookTestEnvironment
|
||||
} {
|
||||
let env: HookTestEnvironment
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
env = await createHookTestEnvironment()
|
||||
|
||||
// Mock StateManager to return test workspace
|
||||
mockStateManager(sandbox, [env.tempDir])
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
getEnv: () => {
|
||||
if (!env) {
|
||||
throw new Error("Test environment not initialized. Called getEnv() outside of test?")
|
||||
}
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks StateManager to return test workspace roots.
|
||||
* Useful for testing hook discovery across multiple workspace roots.
|
||||
*
|
||||
* @param sandbox Sinon sandbox for cleanup
|
||||
* @param workspaceRoots Array of workspace root paths
|
||||
*
|
||||
* @example
|
||||
* const sandbox = sinon.createSandbox()
|
||||
* mockStateManager(sandbox, ["/path/to/workspace1", "/path/to/workspace2"])
|
||||
* // StateManager.get().getGlobalStateKey("workspaceRoots") now returns mocked roots
|
||||
* sandbox.restore() // Clean up after tests
|
||||
*/
|
||||
export function mockStateManager(sandbox: sinon.SinonSandbox, workspaceRoots: string[]): void {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => workspaceRoots.map((rootPath) => ({ path: rootPath })),
|
||||
} as any)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import should from "should"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { Hooks, NamedHookInput } from "../hook-factory"
|
||||
|
||||
// Define HookName locally since it's not exported from hook-factory
|
||||
type HookName = keyof Hooks
|
||||
|
||||
/**
|
||||
* Creates a hooks directory structure at the specified location.
|
||||
*
|
||||
* @param baseDir Base directory where .clinerules/hooks will be created
|
||||
* @returns Path to the created hooks directory
|
||||
*
|
||||
* @example
|
||||
* const hooksDir = await createHooksDirectory("/tmp/test")
|
||||
* // Returns: "/tmp/test/.clinerules/hooks"
|
||||
*/
|
||||
export async function createHooksDirectory(baseDir: string): Promise<string> {
|
||||
const hooksDir = path.join(baseDir, ".clinerules", "hooks")
|
||||
await fs.mkdir(hooksDir, { recursive: true })
|
||||
return hooksDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test hook script with the specified output behavior.
|
||||
* Creates shell scripts that work uniformly on all platforms via embedded shell.
|
||||
* No file extensions are used - the embedded shell handles execution.
|
||||
*
|
||||
* @param baseDir Base directory (typically tempDir from test environment)
|
||||
* @param hookName Name of the hook (e.g., "PreToolUse", "PostToolUse")
|
||||
* @param output The JSON output the hook should return
|
||||
* @param options Optional configuration for hook behavior
|
||||
* @returns Path to the created hook script
|
||||
*
|
||||
* @example
|
||||
* // Create a simple success hook
|
||||
* await createTestHook(tempDir, "PreToolUse", {
|
||||
* shouldContinue: true,
|
||||
* contextModification: "TEST_CONTEXT"
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Create a hook that delays before responding
|
||||
* await createTestHook(tempDir, "PreToolUse", {
|
||||
* shouldContinue: true
|
||||
* }, { delay: 100 })
|
||||
*
|
||||
* @example
|
||||
* // Create a hook that exits with an error
|
||||
* await createTestHook(tempDir, "PreToolUse", {
|
||||
* shouldContinue: false
|
||||
* }, { exitCode: 1 })
|
||||
*
|
||||
* @example
|
||||
* // Create a hook with custom Node.js code
|
||||
* await createTestHook(tempDir, "PreToolUse", {}, {
|
||||
* customNodeCode: "console.log('custom behavior'); process.exit(0);"
|
||||
* })
|
||||
*/
|
||||
export async function createTestHook(
|
||||
baseDir: string,
|
||||
hookName: string,
|
||||
output: Partial<HookOutput>,
|
||||
options: {
|
||||
delay?: number
|
||||
exitCode?: number
|
||||
malformedJson?: boolean
|
||||
customNodeCode?: string
|
||||
exitWithoutOutput?: boolean
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const hooksDir = await createHooksDirectory(baseDir)
|
||||
const scriptContent = generateHookScript(output, options)
|
||||
|
||||
// Create uniform shell script (works on all platforms via embedded shell)
|
||||
return writeShellHook(hooksDir, hookName, scriptContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Node.js script with shebang for Unix systems.
|
||||
*/
|
||||
function generateHookScript(
|
||||
output: Partial<HookOutput>,
|
||||
options: {
|
||||
delay?: number
|
||||
exitCode?: number
|
||||
malformedJson?: boolean
|
||||
customNodeCode?: string
|
||||
exitWithoutOutput?: boolean
|
||||
},
|
||||
): string {
|
||||
let script = "#!/usr/bin/env node\n"
|
||||
|
||||
// If custom Node.js code is provided, use it directly
|
||||
if (options.customNodeCode) {
|
||||
return script + options.customNodeCode
|
||||
}
|
||||
|
||||
// If exitWithoutOutput is true, just exit
|
||||
if (options.exitWithoutOutput) {
|
||||
return script + "process.exit(0);\n"
|
||||
}
|
||||
|
||||
if (options.delay) {
|
||||
script += `setTimeout(() => {\n`
|
||||
}
|
||||
|
||||
if (options.malformedJson) {
|
||||
script += ` console.log("not valid json");\n`
|
||||
} else {
|
||||
script += ` console.log(JSON.stringify(${JSON.stringify(output)}));\n`
|
||||
}
|
||||
|
||||
if (options.exitCode !== undefined) {
|
||||
script += ` process.exit(${options.exitCode});\n`
|
||||
}
|
||||
|
||||
if (options.delay) {
|
||||
script += `}, ${options.delay});\n`
|
||||
}
|
||||
|
||||
return script
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a hook script for Unix systems.
|
||||
*/
|
||||
async function writeShellHook(hooksDir: string, hookName: string, scriptContent: string): Promise<string> {
|
||||
const scriptPath = path.join(hooksDir, hookName)
|
||||
await fs.writeFile(scriptPath, scriptContent)
|
||||
await fs.chmod(scriptPath, 0o755)
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete HookInput object for PreToolUse testing.
|
||||
*
|
||||
* @param params Partial parameters to customize the input
|
||||
* @returns Complete HookInput ready for runner.run()
|
||||
*
|
||||
* @example
|
||||
* const input = buildPreToolUseInput({
|
||||
* toolName: "write_to_file",
|
||||
* parameters: { path: "test.ts", content: "test" }
|
||||
* })
|
||||
*/
|
||||
export function buildPreToolUseInput(params: {
|
||||
toolName: string
|
||||
parameters?: Record<string, any>
|
||||
taskId?: string
|
||||
}): NamedHookInput<"PreToolUse"> {
|
||||
return {
|
||||
taskId: params.taskId || "test-task-id",
|
||||
preToolUse: {
|
||||
toolName: params.toolName,
|
||||
parameters: params.parameters || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete HookInput object for PostToolUse testing.
|
||||
*
|
||||
* @param params Partial parameters to customize the input
|
||||
* @returns Complete HookInput ready for runner.run()
|
||||
*
|
||||
* @example
|
||||
* const input = buildPostToolUseInput({
|
||||
* toolName: "write_to_file",
|
||||
* result: "File created successfully",
|
||||
* success: true
|
||||
* })
|
||||
*/
|
||||
export function buildPostToolUseInput(params: {
|
||||
toolName: string
|
||||
parameters?: Record<string, any>
|
||||
result?: string
|
||||
success?: boolean
|
||||
executionTimeMs?: number
|
||||
taskId?: string
|
||||
}): NamedHookInput<"PostToolUse"> {
|
||||
return {
|
||||
taskId: params.taskId || "test-task-id",
|
||||
postToolUse: {
|
||||
toolName: params.toolName,
|
||||
parameters: params.parameters || {},
|
||||
result: params.result || "",
|
||||
success: params.success ?? true,
|
||||
executionTimeMs: params.executionTimeMs ?? 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion helper for HookOutput validation.
|
||||
* Compares actual output against expected partial output.
|
||||
*
|
||||
* @param actual The actual hook output received
|
||||
* @param expected The expected hook output (partial match)
|
||||
*
|
||||
* @example
|
||||
* assertHookOutput(result, {
|
||||
* shouldContinue: true,
|
||||
* contextModification: "Expected context"
|
||||
* })
|
||||
*/
|
||||
export function assertHookOutput(actual: HookOutput, expected: Partial<HookOutput>): void {
|
||||
if (expected.shouldContinue !== undefined) {
|
||||
if (actual.shouldContinue !== expected.shouldContinue) {
|
||||
throw new Error(
|
||||
`Hook output assertion failed for 'shouldContinue':\n` +
|
||||
` Expected: ${expected.shouldContinue}\n` +
|
||||
` Received: ${actual.shouldContinue}\n` +
|
||||
` Full output: ${JSON.stringify(actual, null, 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (expected.contextModification !== undefined) {
|
||||
if (actual.contextModification !== expected.contextModification) {
|
||||
throw new Error(
|
||||
`Hook output assertion failed for 'contextModification':\n` +
|
||||
` Expected: "${expected.contextModification}"\n` +
|
||||
` Received: "${actual.contextModification}"\n` +
|
||||
` Full output: ${JSON.stringify(actual, null, 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (expected.errorMessage !== undefined) {
|
||||
if (actual.errorMessage !== expected.errorMessage) {
|
||||
throw new Error(
|
||||
`Hook output assertion failed for 'errorMessage':\n` +
|
||||
` Expected: "${expected.errorMessage}"\n` +
|
||||
` Received: "${actual.errorMessage}"\n` +
|
||||
` Full output: ${JSON.stringify(actual, null, 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is serializable (can be cloned).
|
||||
* Prevents errors from attempting to clone non-serializable objects.
|
||||
*/
|
||||
function isSerializable(value: any): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const type = typeof value
|
||||
if (type === "string" || type === "number" || type === "boolean") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
// Check for non-serializable types
|
||||
if (value instanceof Function || value instanceof RegExp || value instanceof Error) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's an array or plain object
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isSerializable)
|
||||
}
|
||||
|
||||
// For objects, check all values
|
||||
return Object.values(value).every(isSerializable)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation of HookRunner for fast integration tests.
|
||||
* Tracks calls and returns predefined responses without spawning processes.
|
||||
*
|
||||
* @example
|
||||
* const mockRunner = new MockHookRunner("PreToolUse")
|
||||
* mockRunner.setResponse({ shouldContinue: true })
|
||||
*
|
||||
* const result = await mockRunner.run(input)
|
||||
* mockRunner.assertCalled(1)
|
||||
* mockRunner.assertCalledWith({ preToolUse: { toolName: "write_to_file" } })
|
||||
*/
|
||||
export class MockHookRunner<Name extends HookName> {
|
||||
private response: HookOutput = {
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: "",
|
||||
}
|
||||
public executionLog: Array<{ input: NamedHookInput<Name>; timestamp: number }> = []
|
||||
public readonly hookName: Name
|
||||
|
||||
constructor(hookName: Name) {
|
||||
this.hookName = hookName
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response this mock should return.
|
||||
*
|
||||
* @param output The HookOutput to return on execution
|
||||
*/
|
||||
setResponse(output: Partial<HookOutput>): void {
|
||||
this.response = {
|
||||
shouldContinue: output.shouldContinue ?? true,
|
||||
contextModification: output.contextModification ?? "",
|
||||
errorMessage: output.errorMessage ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock run method that records calls and returns preset response.
|
||||
* Does not use the actual HookRunner execution mechanism.
|
||||
*/
|
||||
async run(params: NamedHookInput<Name>): Promise<HookOutput> {
|
||||
// Validate params are serializable
|
||||
if (!isSerializable(params)) {
|
||||
throw new Error(
|
||||
`MockHookRunner: Cannot clone non-serializable input. ` +
|
||||
`Ensure all input values are primitive types, arrays, or plain objects.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Use structuredClone for deep copy (Node 17+)
|
||||
// Falls back to JSON stringify/parse for older Node versions
|
||||
let clonedInput: NamedHookInput<Name>
|
||||
try {
|
||||
clonedInput = structuredClone(params)
|
||||
} catch {
|
||||
// Fallback for older Node versions
|
||||
clonedInput = JSON.parse(JSON.stringify(params))
|
||||
}
|
||||
|
||||
this.executionLog.push({
|
||||
input: clonedInput,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
// Simulate async execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
|
||||
return this.response
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this hook was called a specific number of times.
|
||||
*
|
||||
* @param times Expected number of calls
|
||||
*/
|
||||
assertCalled(times: number): void {
|
||||
if (this.executionLog.length !== times) {
|
||||
throw new Error(
|
||||
`MockHookRunner call count assertion failed:\n` +
|
||||
` Expected: ${times} calls\n` +
|
||||
` Received: ${this.executionLog.length} calls\n` +
|
||||
` Execution log:\n${JSON.stringify(this.executionLog, null, 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this hook was called with matching input.
|
||||
* Performs partial match on the input object using deep equality.
|
||||
* Property ordering does not affect equality checks.
|
||||
* Uses should.js's eql() for robust deep equality comparison.
|
||||
*
|
||||
* @param matcher Partial input to match against
|
||||
*/
|
||||
assertCalledWith(matcher: Partial<NamedHookInput<Name>>): void {
|
||||
const matchingCalls = this.executionLog.filter((log) => {
|
||||
return Object.keys(matcher).every((key) => {
|
||||
const matcherValue = (matcher as any)[key]
|
||||
const logValue = (log.input as any)[key]
|
||||
// Use should.js's eql() for deep equality (handles property ordering)
|
||||
try {
|
||||
should(logValue).eql(matcherValue)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (matchingCalls.length === 0) {
|
||||
throw new Error(
|
||||
`MockHookRunner input assertion failed - no calls matched the expected input:\n` +
|
||||
` Expected input (partial): ${JSON.stringify(matcher, null, 2)}\n` +
|
||||
` Actual calls: ${JSON.stringify(this.executionLog, null, 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all recorded calls and responses.
|
||||
*/
|
||||
reset(): void {
|
||||
this.executionLog = []
|
||||
this.response = {
|
||||
shouldContinue: true,
|
||||
contextModification: "",
|
||||
errorMessage: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a fixture to the test environment.
|
||||
*
|
||||
* @param fixtureName Path to fixture relative to fixtures directory (e.g., "hooks/pretooluse/success")
|
||||
* @param destDir Destination directory (typically tempDir from test environment)
|
||||
*
|
||||
* @example
|
||||
* await loadFixture("hooks/pretooluse/success", tempDir)
|
||||
* // Hook is now available at tempDir/.clinerules/hooks/PreToolUse
|
||||
*/
|
||||
export async function loadFixture(fixtureName: string, destDir: string): Promise<void> {
|
||||
const fixturesDir = path.join(__dirname, "fixtures")
|
||||
const sourcePath = path.join(fixturesDir, fixtureName)
|
||||
const destHooksDir = await createHooksDirectory(destDir)
|
||||
|
||||
// Copy all files from the fixture directory to the destination
|
||||
const files = await fs.readdir(sourcePath)
|
||||
for (const file of files) {
|
||||
const sourceFile = path.join(sourcePath, file)
|
||||
const destFile = path.join(destHooksDir, file)
|
||||
await fs.copyFile(sourceFile, destFile)
|
||||
|
||||
// Preserve executable permissions on Unix
|
||||
if (process.platform !== "win32") {
|
||||
const stats = await fs.stat(sourceFile)
|
||||
await fs.chmod(destFile, stats.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,14 @@ export class ToolExecutor {
|
||||
* Adds hook context modification to the conversation if provided.
|
||||
* Parses the context to extract type prefix and formats as XML.
|
||||
*
|
||||
* Context Type Prefix Format:
|
||||
* - Type prefixes MUST be uppercase (A-Z and underscores only)
|
||||
* - Format: "TYPE_PREFIX: context content"
|
||||
* - Valid examples: "WORKSPACE_RULES:", "FILE_OPERATIONS:", "VALIDATION:"
|
||||
* - Invalid examples: "workspace_rules:", "Workspace_Rules:" (lowercase not matched)
|
||||
*
|
||||
* If no valid uppercase type prefix is found, defaults to type="general"
|
||||
*
|
||||
* @param contextModification The context string from the hook output
|
||||
* @param source The hook source name ("PreToolUse" or "PostToolUse")
|
||||
*/
|
||||
@@ -369,7 +377,8 @@ export class ToolExecutor {
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
|
||||
// Check if first line specifies a type: "TYPE: content"
|
||||
// Type prefix MUST be uppercase: matches "TYPE_PREFIX: content"
|
||||
// Only uppercase letters (A-Z) and underscores are recognized
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { buildPostToolUseInput, MockHookRunner } from "../../hooks/__tests__/test-utils"
|
||||
|
||||
/**
|
||||
* Integration tests for ToolExecutor with hooks.
|
||||
*
|
||||
* These tests demonstrate hook integration patterns using MockHookRunner
|
||||
* for fast execution without spawning processes. They show how hooks are
|
||||
* called at appropriate times and how their results affect tool execution.
|
||||
*
|
||||
* Note: Real ToolExecutor integration would require additional test infrastructure
|
||||
* including actual ToolExecutor instances and more comprehensive mocking.
|
||||
*/
|
||||
describe("ToolExecutor Hook Orchestration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("MockHookRunner integration patterns", () => {
|
||||
it("should track PreToolUse hook calls with MockHookRunner", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "WORKSPACE_RULES: PreToolUse hook allows execution",
|
||||
})
|
||||
|
||||
// Simulate calling the hook (normally done by HookFactory/ToolExecutor)
|
||||
const input = {
|
||||
clineVersion: "1.0.0",
|
||||
hookName: "PreToolUse",
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: "test-task",
|
||||
workspaceRoots: ["/test/workspace"],
|
||||
userId: "test-user",
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "test.ts", content: "test" },
|
||||
},
|
||||
} as const
|
||||
|
||||
const result = await mockRunner.run(input as any)
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification!.should.equal("WORKSPACE_RULES: PreToolUse hook allows execution")
|
||||
|
||||
mockRunner.assertCalled(1)
|
||||
mockRunner.assertCalledWith({
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "test.ts", content: "test" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should demonstrate hook blocking behavior with MockHookRunner", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: false,
|
||||
errorMessage: "Insufficient permissions",
|
||||
})
|
||||
|
||||
const input = {
|
||||
clineVersion: "1.0.0",
|
||||
hookName: "PreToolUse",
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "/etc/passwd", content: "malicious" }, // Would need blocking
|
||||
},
|
||||
} as const
|
||||
|
||||
const result = await mockRunner.run(input as any)
|
||||
result.shouldContinue.should.be.false()
|
||||
result.errorMessage!.should.equal("Insufficient permissions")
|
||||
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
|
||||
it("should show state persistence across mock calls", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
// Multiple calls to demonstrate state persistence
|
||||
const input1 = { preToolUse: { toolName: "read_file" } } as const
|
||||
const input2 = { preToolUse: { toolName: "write_file" } } as const
|
||||
|
||||
await mockRunner.run(input1 as any)
|
||||
await mockRunner.run(input2 as any)
|
||||
|
||||
mockRunner.assertCalled(2)
|
||||
|
||||
// Can inspect all calls
|
||||
mockRunner.executionLog.should.have.length(2)
|
||||
mockRunner.executionLog[0].input.preToolUse.toolName.should.equal("read_file")
|
||||
mockRunner.executionLog[1].input.preToolUse.toolName.should.equal("write_file")
|
||||
})
|
||||
|
||||
it("should reset MockHookRunner state between tests", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
await mockRunner.run({ preToolUse: { toolName: "test" } } as any)
|
||||
mockRunner.assertCalled(1)
|
||||
|
||||
mockRunner.reset()
|
||||
mockRunner.assertCalled(0)
|
||||
|
||||
await mockRunner.run({ preToolUse: { toolName: "test2" } } as any)
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PostToolUse hook integration patterns", () => {
|
||||
it("should demonstrate PostToolUse input structure with buildPostToolUseInput", async () => {
|
||||
const mockRunner = new MockHookRunner("PostToolUse")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
// Use the testing utility to build proper input structure
|
||||
const postUseInput = buildPostToolUseInput({
|
||||
toolName: "write_to_file",
|
||||
result: "File created successfully",
|
||||
success: true,
|
||||
executionTimeMs: 250,
|
||||
})
|
||||
|
||||
// Verify input structure (normally done by ToolExecutor)
|
||||
postUseInput.postToolUse.toolName.should.equal("write_to_file")
|
||||
postUseInput.postToolUse.result.should.equal("File created successfully")
|
||||
postUseInput.postToolUse.success.should.be.true()
|
||||
postUseInput.postToolUse.executionTimeMs.should.equal(250)
|
||||
postUseInput.postToolUse.parameters.should.eql({})
|
||||
|
||||
// Simulate what PostToolUse hook would receive
|
||||
const result = await mockRunner.run(postUseInput)
|
||||
result.shouldContinue.should.be.true()
|
||||
})
|
||||
|
||||
it("should show PostToolUse handling failed tool execution", async () => {
|
||||
const mockRunner = new MockHookRunner("PostToolUse")
|
||||
mockRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
const postUseInput = buildPostToolUseInput({
|
||||
toolName: "run_command",
|
||||
parameters: { command: "forbidden_command" },
|
||||
result: "Command failed: permission denied",
|
||||
success: false,
|
||||
executionTimeMs: 50,
|
||||
})
|
||||
|
||||
const result = await mockRunner.run(postUseInput)
|
||||
result.shouldContinue.should.be.true() // PostToolUse can still succeed
|
||||
|
||||
mockRunner.assertCalledWith({
|
||||
postToolUse: {
|
||||
toolName: "run_command",
|
||||
parameters: { command: "forbidden_command" },
|
||||
result: "Command failed: permission denied",
|
||||
success: false,
|
||||
executionTimeMs: 50,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hook fixtures integration", () => {
|
||||
it("should demonstrate MockHookRunner call tracking", async () => {
|
||||
// This demonstrates MockHookRunner functionality with call tracking
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "FIXTURE_CONTEXT: Mock hook behavior",
|
||||
})
|
||||
|
||||
const input1 = { preToolUse: { toolName: "test_tool" } } as const
|
||||
const input2 = { preToolUse: { toolName: "test_tool2" } } as const
|
||||
|
||||
// Track multiple calls
|
||||
await mockRunner.run(input1 as any)
|
||||
await mockRunner.run(input2 as any)
|
||||
|
||||
mockRunner.assertCalled(2)
|
||||
mockRunner.executionLog.should.have.length(2)
|
||||
mockRunner.executionLog[0].input.preToolUse.toolName.should.equal("test_tool")
|
||||
mockRunner.executionLog[1].input.preToolUse.toolName.should.equal("test_tool2")
|
||||
})
|
||||
|
||||
it("should support timeout error simulation with MockHookRunner", async () => {
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: false,
|
||||
errorMessage: "Hook timeout after 30s",
|
||||
})
|
||||
|
||||
// Mock response represents what would happen in timeout scenario
|
||||
const result = await mockRunner.run({ preToolUse: { toolName: "timeout_test" } } as any)
|
||||
result.shouldContinue.should.be.false()
|
||||
result.errorMessage.should.equal("Hook timeout after 30s")
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hook orchestration workflow simulation", () => {
|
||||
it("should demonstrate complete PreToolUse → Tool → PostToolUse workflow", async () => {
|
||||
const preRunner = new MockHookRunner("PreToolUse")
|
||||
const postRunner = new MockHookRunner("PostToolUse")
|
||||
|
||||
// 1. PreToolUse hook allows execution
|
||||
preRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "WORKSPACE_RULES: Tool approved",
|
||||
})
|
||||
|
||||
// 2. Tool execution succeeds
|
||||
const toolResult = {
|
||||
success: true,
|
||||
result: "File edited successfully",
|
||||
executionTimeMs: 150,
|
||||
}
|
||||
|
||||
// 3. PostToolUse hook processes results
|
||||
postRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "FILE_OPERATIONS: Operation logged",
|
||||
})
|
||||
|
||||
// Simulate workflow execution order
|
||||
const preInput = { preToolUse: { toolName: "edit_file" } } as const
|
||||
const preResult = await preRunner.run(preInput as any)
|
||||
preResult.shouldContinue.should.be.true()
|
||||
|
||||
const postInput = buildPostToolUseInput({
|
||||
toolName: "edit_file",
|
||||
result: toolResult.result,
|
||||
success: toolResult.success,
|
||||
executionTimeMs: toolResult.executionTimeMs,
|
||||
})
|
||||
const postResult = await postRunner.run(postInput)
|
||||
postResult.shouldContinue.should.be.true()
|
||||
|
||||
// Verify both hooks were called appropriately
|
||||
preRunner.assertCalled(1)
|
||||
postRunner.assertCalled(1)
|
||||
})
|
||||
|
||||
it("should show workflow interruption when PreToolUse blocks", async () => {
|
||||
const preRunner = new MockHookRunner("PreToolUse")
|
||||
const postRunner = new MockHookRunner("PostToolUse")
|
||||
|
||||
// PreToolUse blocks execution
|
||||
preRunner.setResponse({
|
||||
shouldContinue: false,
|
||||
errorMessage: "Security violation detected",
|
||||
})
|
||||
|
||||
// PostToolUse should not be called
|
||||
postRunner.setResponse({ shouldContinue: true })
|
||||
|
||||
// Test workflow interruption
|
||||
const preInput = { preToolUse: { toolName: "dangerous_operation" } } as const
|
||||
const preResult = await preRunner.run(preInput as any)
|
||||
preResult.shouldContinue.should.be.false()
|
||||
|
||||
// PostToolUse would NOT be called in this scenario
|
||||
postRunner.assertCalled(0)
|
||||
preRunner.assertCalled(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Testing infrastructure validation", () => {
|
||||
it("should work with MockHookRunner patterns", async () => {
|
||||
// This demonstrates MockHookRunner functionality without real hooks
|
||||
const mockRunner = new MockHookRunner("PreToolUse")
|
||||
mockRunner.setResponse({
|
||||
shouldContinue: true,
|
||||
contextModification: "INFRASTRUCTURE_TEST: Infrastructure working",
|
||||
})
|
||||
|
||||
const result = await mockRunner.run({
|
||||
preToolUse: {
|
||||
toolName: "test_validation",
|
||||
parameters: {},
|
||||
},
|
||||
} as any)
|
||||
|
||||
result.shouldContinue.should.be.true()
|
||||
result.contextModification.should.equal("INFRASTRUCTURE_TEST: Infrastructure working")
|
||||
mockRunner.assertCalled(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,370 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
|
||||
/**
|
||||
* Escapes special XML characters to prevent malformed XML output.
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for building hook context XML (extracted from ToolExecutor logic).
|
||||
* Properly escapes XML special characters to prevent malformed or insecure XML.
|
||||
*
|
||||
* Context Type Prefix Format:
|
||||
* - Type prefixes MUST be uppercase (A-Z and underscores only)
|
||||
* - Format: "TYPE_PREFIX: context content"
|
||||
* - Valid examples: "WORKSPACE_RULES:", "FILE_OPERATIONS:", "VALIDATION:"
|
||||
* - Invalid examples: "workspace_rules:", "Workspace_Rules:" (lowercase not matched)
|
||||
*
|
||||
* If no valid uppercase type prefix is found, defaults to type="general"
|
||||
*/
|
||||
function buildHookContextXml(source: string, contextModification?: string): string {
|
||||
if (!contextModification) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const contextText = contextModification.trim()
|
||||
if (!contextText) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
|
||||
// Type prefix MUST be uppercase: matches "TYPE_PREFIX: content"
|
||||
// Only uppercase letters (A-Z) and underscores are recognized
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
// Escape XML special characters in all values
|
||||
const escapedSource = escapeXml(source)
|
||||
const escapedType = escapeXml(contextType)
|
||||
const escapedContent = escapeXml(content)
|
||||
|
||||
return `<hook_context source="${escapedSource}" type="${escapedType}">\n${escapedContent}\n</hook_context>`
|
||||
}
|
||||
|
||||
describe("ToolExecutor Hook Integration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("addHookContextToConversation", () => {
|
||||
it("should handle undefined context", () => {
|
||||
// Test that undefined context doesn't add anything
|
||||
const userMessageContent: any[] = []
|
||||
|
||||
// Simulate the method behavior - undefined context should not add anything
|
||||
const contextModification: string | undefined = undefined
|
||||
// The implementation checks for truthiness, which excludes undefined
|
||||
if (contextModification) {
|
||||
userMessageContent.push({ type: "text", text: "should not reach here" })
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(0)
|
||||
// Import the actual production function from hook-utils that the ToolExecutor uses
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
result.should.equal("")
|
||||
})
|
||||
|
||||
it("should handle empty context", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const contextModification = ""
|
||||
|
||||
// Simulate the method behavior - empty string is falsy in if check
|
||||
const contextModification: string | undefined = ""
|
||||
// Empty string is falsy, so this block won't execute
|
||||
if (contextModification) {
|
||||
userMessageContent.push({ type: "text", text: "should not reach here" })
|
||||
}
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
userMessageContent.length.should.equal(0)
|
||||
result.should.equal("")
|
||||
})
|
||||
|
||||
it("should handle whitespace-only context", () => {
|
||||
const userMessageContent: any[] = []
|
||||
|
||||
// Simulate the method behavior
|
||||
const contextModification = " \n \t "
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
userMessageContent.push({ type: "text", text: "should not reach here" })
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(0)
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
result.should.equal("")
|
||||
})
|
||||
|
||||
it("should add context without type prefix", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "Simple context message"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(1)
|
||||
userMessageContent[0].text.should.match(/type="general"/)
|
||||
userMessageContent[0].text.should.match(/Simple context message/)
|
||||
userMessageContent[0].text.should.match(/source="PreToolUse"/)
|
||||
result.should.match(/type="general"/)
|
||||
result.should.match(/Simple context message/)
|
||||
result.should.match(/source="PreToolUse"/)
|
||||
})
|
||||
|
||||
it("should extract type from WORKSPACE_RULES prefix", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "WORKSPACE_RULES: Follow TypeScript conventions"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(1)
|
||||
userMessageContent[0].text.should.match(/type="workspace_rules"/)
|
||||
userMessageContent[0].text.should.match(/Follow TypeScript conventions/)
|
||||
userMessageContent[0].text.should.not.match(/WORKSPACE_RULES:/)
|
||||
result.should.match(/type="workspace_rules"/)
|
||||
result.should.match(/Follow TypeScript conventions/)
|
||||
result.should.not.match(/WORKSPACE_RULES:/)
|
||||
})
|
||||
|
||||
it("should extract type from FILE_OPERATIONS prefix", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PostToolUse"
|
||||
const contextModification = "FILE_OPERATIONS: Created file.ts successfully"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PostToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(1)
|
||||
userMessageContent[0].text.should.match(/type="file_operations"/)
|
||||
userMessageContent[0].text.should.match(/Created file\.ts successfully/)
|
||||
result.should.match(/type="file_operations"/)
|
||||
result.should.match(/Created file\.ts successfully/)
|
||||
})
|
||||
|
||||
it("should handle multi-line context with type", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "VALIDATION: First line content\nSecond line of context\nThird line of context"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(1)
|
||||
userMessageContent[0].text.should.match(/type="validation"/)
|
||||
userMessageContent[0].text.should.match(/First line content/)
|
||||
userMessageContent[0].text.should.match(/Second line/)
|
||||
userMessageContent[0].text.should.match(/Third line/)
|
||||
result.should.match(/type="validation"/)
|
||||
result.should.match(/First line content/)
|
||||
result.should.match(/Second line/)
|
||||
result.should.match(/Third line/)
|
||||
})
|
||||
|
||||
it("should handle multi-line context with type but no content on first line", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "PERFORMANCE:\nTool execution took longer than expected\nConsider optimization"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent.length.should.equal(1)
|
||||
userMessageContent[0].text.should.match(/type="performance"/)
|
||||
userMessageContent[0].text.should.match(/Tool execution took/)
|
||||
userMessageContent[0].text.should.match(/Consider optimization/)
|
||||
result.should.match(/type="performance"/)
|
||||
result.should.match(/Tool execution took/)
|
||||
result.should.match(/Consider optimization/)
|
||||
})
|
||||
|
||||
it("should preserve source parameter correctly", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PostToolUse"
|
||||
const contextModification = "Some context"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PostToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent[0].text.should.match(/source="PostToolUse"/)
|
||||
result.should.match(/source="PostToolUse"/)
|
||||
})
|
||||
|
||||
it("should handle type with underscores", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "MY_CUSTOM_TYPE: Custom context"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMessageContent[0].text.should.match(/type="my_custom_type"/)
|
||||
result.should.match(/type="my_custom_type"/)
|
||||
})
|
||||
|
||||
it("should not match lowercase type prefix", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "lowercase_type: This should not be extracted as type"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
// Should use default "general" type since lowercase doesn't match
|
||||
userMessageContent[0].text.should.match(/type="general"/)
|
||||
userMessageContent[0].text.should.match(/lowercase_type:/)
|
||||
result.should.match(/type="general"/)
|
||||
result.should.match(/lowercase_type:/)
|
||||
})
|
||||
|
||||
it("should filter out empty lines when extracting multi-line content", () => {
|
||||
const userMessageContent: any[] = []
|
||||
const source = "PreToolUse"
|
||||
const contextModification = "TEST_TYPE: First line\n\n\nSecond line\n \nThird line"
|
||||
|
||||
// Simulate the method behavior
|
||||
if (contextModification) {
|
||||
const contextText = contextModification.trim()
|
||||
if (contextText) {
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
|
||||
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
|
||||
const typeMatch = typeMatchRegex.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
// Verify the content contains the expected lines
|
||||
userMessageContent[0].text.should.match(/First line/)
|
||||
userMessageContent[0].text.should.match(/Second line/)
|
||||
userMessageContent[0].text.should.match(/Third line/)
|
||||
// Verify empty lines were filtered out
|
||||
userMessageContent[0].text.should.not.match(/First line\n\n/)
|
||||
userMessageContent[0].text.should.not.match(/Second line\n\n/)
|
||||
result.should.match(/First line/)
|
||||
result.should.match(/Second line/)
|
||||
result.should.match(/Third line/)
|
||||
// Verify empty lines were filtered out (this would be in the actual content parsing)
|
||||
// but this test is mainly to verify the function works with complex inputs
|
||||
})
|
||||
})
|
||||
|
||||
@@ -390,5 +192,56 @@ describe("ToolExecutor Hook Integration", () => {
|
||||
|
||||
xml.should.match(new RegExp(content.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
})
|
||||
|
||||
it("should properly escape XML special characters", () => {
|
||||
const contextModification = "TEST: Content with <tags> & \"quotes\" and 'apostrophes'"
|
||||
|
||||
const result = buildHookContextXml("PreToolUse", contextModification)
|
||||
|
||||
// Verify XML special characters are escaped in the result
|
||||
result.should.match(/<tags>/)
|
||||
result.should.match(/&/)
|
||||
result.should.match(/"quotes"/)
|
||||
result.should.match(/'apostrophes'/)
|
||||
|
||||
// Verify no literal < or > characters in content (except in XML structure)
|
||||
// Extract just the content between tags
|
||||
const contentMatch = result.match(/<hook_context[^>]*>\n(.*)\n<\/hook_context>/)
|
||||
if (contentMatch) {
|
||||
const content = contentMatch[1]
|
||||
// Verify all angle brackets are escaped
|
||||
// Content should only have < and >, never bare < or >
|
||||
const unescapedAngles = content.match(/[^&]</g) || content.match(/[^;]>/g)
|
||||
if (unescapedAngles) {
|
||||
throw new Error(`Found unescaped angle brackets in content: ${unescapedAngles}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should escape special characters in source attribute", () => {
|
||||
const source = "Pre<Tool>Use"
|
||||
const contextModification = "TEST_TYPE: Content"
|
||||
|
||||
const result = buildHookContextXml(source, contextModification)
|
||||
|
||||
// Source should be escaped in attribute
|
||||
result.should.match(/source="Pre<Tool>Use"/)
|
||||
// Type is valid and should be extracted normally
|
||||
result.should.match(/type="test_type"/)
|
||||
})
|
||||
|
||||
it("should escape special characters in content when type extraction fails", () => {
|
||||
const source = "PreToolUse"
|
||||
// This won't match the type pattern due to < in it
|
||||
const contextModification = "MY<TYPE>: Content with <special> chars"
|
||||
|
||||
const result = buildHookContextXml(source, contextModification)
|
||||
|
||||
// Should use default type since pattern doesn't match
|
||||
result.should.match(/type="general"/)
|
||||
// Content should have escaped special characters
|
||||
result.should.match(/MY<TYPE>/)
|
||||
result.should.match(/<special>/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user