Borg/pkg/hooks/hooks_test.go
google-labs-jules[bot] 07de6d5877 feat: Implement collection hooks/plugins system
Adds a flexible hook system to the `borg collect` commands, allowing users to run custom scripts at various stages of the collection lifecycle.

This feature introduces a new `pkg/hooks` package that encapsulates the core logic for parsing a `.borg-hooks.yaml` configuration file and executing external scripts.

Key features:
- Four hook events are supported: `on_file_collected`, `on_url_found`, `on_collection_complete`, and `on_error`.
- A `--hooks` flag has been added to the `collect website` and `collect pwa` commands.
- The system automatically detects and loads a `.borg-hooks.yaml` file from the current directory if the `--hooks` flag is not provided.
- File-based hooks (`on_file_collected`) support glob pattern matching against the base filename.
- Hook scripts receive a JSON payload on stdin with relevant event context.
- Commands with arguments are correctly handled by executing them through `sh -c`.

The implementation includes a comprehensive test suite with both unit tests for the new `hooks` package and integration tests to validate the end-to-end functionality. All existing tests and examples have been updated to reflect the necessary function signature changes.

Co-authored-by: Snider <631881+Snider@users.noreply.github.com>
2026-02-02 00:49:08 +00:00

218 lines
5.1 KiB
Go

package hooks
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTest(t *testing.T) (string, func()) {
t.Helper()
// Create a temporary directory for test artifacts
tmpDir, err := os.MkdirTemp("", "hooks-test-")
require.NoError(t, err)
// Create test hook script
scriptContent := "#!/bin/sh\ncat > " + filepath.Join(tmpDir, "testhook.output")
scriptPath := filepath.Join(tmpDir, "testhook.sh")
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
require.NoError(t, err)
// Create test hooks config
hooksYAML := `
hooks:
on_file_collected:
- pattern: "*.pdf"
run: "` + scriptPath + `"
- pattern: "*.txt"
run: "` + scriptPath + `"
on_url_found:
- run: "` + scriptPath + `"
on_collection_complete:
- run: "` + scriptPath + `"
on_error:
- run: "` + scriptPath + `"
`
configPath := filepath.Join(tmpDir, ".borg-hooks.yaml")
err = os.WriteFile(configPath, []byte(hooksYAML), 0644)
require.NoError(t, err)
return tmpDir, func() {
os.RemoveAll(tmpDir)
}
}
func TestNewHookRunner(t *testing.T) {
tmpDir, cleanup := setupTest(t)
defer cleanup()
configPath := filepath.Join(tmpDir, ".borg-hooks.yaml")
// Test with a valid config file
runner, err := NewHookRunner(configPath)
require.NoError(t, err)
assert.NotNil(t, runner)
assert.NotNil(t, runner.config)
// Test with a non-existent file
_, err = NewHookRunner("non-existent-file.yaml")
assert.Error(t, err)
// Test with a malformed file
malformedConfigPath := filepath.Join(tmpDir, "malformed.yaml")
err = os.WriteFile(malformedConfigPath, []byte("hooks: \n - invalid"), 0644)
require.NoError(t, err)
_, err = NewHookRunner(malformedConfigPath)
assert.Error(t, err)
// Test with an empty config file path (should not error)
runner, err = NewHookRunner("")
require.NoError(t, err)
assert.NotNil(t, runner)
assert.NotNil(t, runner.config)
}
func TestHookRunner_Trigger(t *testing.T) {
tmpDir, cleanup := setupTest(t)
defer cleanup()
configPath := filepath.Join(tmpDir, ".borg-hooks.yaml")
runner, err := NewHookRunner(configPath)
require.NoError(t, err)
outputFile := filepath.Join(tmpDir, "testhook.output")
tests := []struct {
name string
event Event
shouldTrigger bool
expectedEvent Event
}{
{
name: "OnFileCollected - PDF Match with full path",
event: Event{
Event: OnFileCollected,
File: "assets/document.pdf",
URL: "http://example.com/assets/document.pdf",
Type: "application/pdf",
},
shouldTrigger: true,
expectedEvent: Event{
Event: OnFileCollected,
File: "assets/document.pdf",
URL: "http://example.com/assets/document.pdf",
Type: "application/pdf",
},
},
{
name: "OnFileCollected - TXT Match with full path",
event: Event{
Event: OnFileCollected,
File: "notes/notes.txt",
},
shouldTrigger: true,
expectedEvent: Event{
Event: OnFileCollected,
File: "notes/notes.txt",
},
},
{
name: "OnFileCollected - No Match",
event: Event{
Event: OnFileCollected,
File: "image.jpg",
},
shouldTrigger: false,
},
{
name: "OnURLFound",
event: Event{
Event: OnURLFound,
URL: "http://example.com/page2",
},
shouldTrigger: true,
expectedEvent: Event{
Event: OnURLFound,
URL: "http://example.com/page2",
},
},
{
name: "OnCollectionComplete",
event: Event{
Event: OnCollectionComplete,
},
shouldTrigger: true,
expectedEvent: Event{
Event: OnCollectionComplete,
},
},
{
name: "OnError",
event: Event{
Event: OnError,
Error: "something went wrong",
},
shouldTrigger: true,
expectedEvent: Event{
Event: OnError,
Error: "something went wrong",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up previous output
_ = os.Remove(outputFile)
err := runner.Trigger(tt.event)
require.NoError(t, err)
if !tt.shouldTrigger {
_, err := os.Stat(outputFile)
assert.True(t, os.IsNotExist(err), "Hook should not have been triggered")
return
}
// Verify the output file was created and contains the correct JSON
content, err := os.ReadFile(outputFile)
require.NoError(t, err)
var receivedEvent Event
err = json.Unmarshal(content, &receivedEvent)
require.NoError(t, err)
assert.Equal(t, tt.expectedEvent, receivedEvent)
})
}
}
func TestHookRunner_FailingHook(t *testing.T) {
tmpDir, cleanup := setupTest(t)
defer cleanup()
// Create a failing script
scriptContent := "#!/bin/sh\nexit 1"
scriptPath := filepath.Join(tmpDir, "failing-hook.sh")
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755)
require.NoError(t, err)
// Create hooks config with the failing script
hooksYAML := `
hooks:
on_error:
- run: "` + scriptPath + `"
`
configPath := filepath.Join(tmpDir, "failing-hooks.yaml")
err = os.WriteFile(configPath, []byte(hooksYAML), 0644)
require.NoError(t, err)
runner, err := NewHookRunner(configPath)
require.NoError(t, err)
err = runner.Trigger(Event{Event: OnError, Error: "test error"})
assert.Error(t, err)
}