mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
refactor: address PR review feedback
- Extract InputSentinel constant to shared constants.go file - Remove duplicate inputSentinel definitions from template.go and patterns.go - Create withTestExtension helper function to reduce test code duplication - Refactor 3 test functions to use the helper (reduces ~40 lines per test) - Fix shell script to use $@ instead of $* for proper argument quoting Addresses review comments from @ksylvan and @Copilot AI
This commit is contained in:
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/danielmiessler/fabric/internal/util"
|
||||
)
|
||||
|
||||
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
|
||||
type PatternsEntity struct {
|
||||
*StorageEntity
|
||||
SystemPatternFile string
|
||||
@@ -96,7 +94,7 @@ func (o *PatternsEntity) applyVariables(
|
||||
|
||||
// Temporarily replace {{input}} with a sentinel token to protect it
|
||||
// from recursive variable resolution
|
||||
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", inputSentinel)
|
||||
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", template.InputSentinel)
|
||||
|
||||
// Process all other template variables in the pattern
|
||||
// Pass the actual input so extension calls can use {{input}} within their value parameter
|
||||
@@ -107,7 +105,7 @@ func (o *PatternsEntity) applyVariables(
|
||||
|
||||
// Finally, replace our sentinel with the actual user input
|
||||
// The input has already been processed for variables if InputHasVars was true
|
||||
pattern.Pattern = strings.ReplaceAll(processed, inputSentinel, input)
|
||||
pattern.Pattern = strings.ReplaceAll(processed, template.InputSentinel, input)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
5
internal/plugins/template/constants.go
Normal file
5
internal/plugins/template/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package template
|
||||
|
||||
// InputSentinel is used to temporarily replace {{input}} during template processing
|
||||
// to prevent recursive variable resolution
|
||||
const InputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
@@ -10,10 +10,6 @@ import (
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
)
|
||||
|
||||
// inputSentinel is used to temporarily replace {{input}} during template processing
|
||||
// to prevent recursive variable resolution
|
||||
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
|
||||
var (
|
||||
textPlugin = &TextPlugin{}
|
||||
datetimePlugin = &DateTimePlugin{}
|
||||
@@ -77,8 +73,8 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s
|
||||
// Extension call
|
||||
if strings.HasPrefix(raw, "ext:") {
|
||||
if name, operation, value, ok := matchTriple(extensionPattern, full); ok {
|
||||
if strings.Contains(value, inputSentinel) {
|
||||
value = strings.ReplaceAll(value, inputSentinel, input)
|
||||
if strings.Contains(value, InputSentinel) {
|
||||
value = strings.ReplaceAll(value, InputSentinel, input)
|
||||
debugf("Replaced sentinel in extension value with input\n")
|
||||
}
|
||||
debugf("Extension call: name=%s operation=%s value=%s\n", name, operation, value)
|
||||
@@ -132,7 +128,7 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s
|
||||
|
||||
// Variables / input / sentinel
|
||||
switch raw {
|
||||
case "input", inputSentinel:
|
||||
case "input", InputSentinel:
|
||||
content = strings.ReplaceAll(content, full, input)
|
||||
progress = true
|
||||
default:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSentinelTokenReplacement tests the fix for the {{input}} sentinel token bug
|
||||
// This test verifies that when {{input}} is used inside an extension call,
|
||||
// the actual input is passed to the extension, not the sentinel token.
|
||||
func TestSentinelTokenReplacement(t *testing.T) {
|
||||
// withTestExtension creates a temporary test extension and runs the test function
|
||||
func withTestExtension(t *testing.T, name string, scriptContent string, testFunc func(*ExtensionManager, string)) {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
@@ -27,23 +28,20 @@ func TestSentinelTokenReplacement(t *testing.T) {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script that echoes what it receives
|
||||
scriptPath := filepath.Join(binDir, "echo-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "RECEIVED: $*"
|
||||
`
|
||||
// Create a test script
|
||||
scriptPath := filepath.Join(binDir, name+".sh")
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "echo-test.yaml")
|
||||
configContent := `name: echo-test
|
||||
executable: ` + scriptPath + `
|
||||
configPath := filepath.Join(configsDir, name+".yaml")
|
||||
configContent := fmt.Sprintf(`name: %s
|
||||
executable: %s
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Echo test extension"
|
||||
description: "Test extension"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
@@ -53,80 +51,96 @@ operations:
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
`, name, scriptPath)
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
mgr := NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
err = mgr.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
input string
|
||||
wantContain string
|
||||
wantNotContain string
|
||||
}{
|
||||
{
|
||||
name: "sentinel token with {{input}} in extension value",
|
||||
template: "{{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "test input data",
|
||||
wantContain: "RECEIVED: test input data",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "direct input variable replacement",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Hello World",
|
||||
wantContain: "RECEIVED: Hello World",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
{
|
||||
name: "sentinel with complex input",
|
||||
template: "Result: {{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "What is AI?",
|
||||
wantContain: "RECEIVED: What is AI?",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "multiple words in input",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Multiple word input string",
|
||||
wantContain: "RECEIVED: Multiple word input string",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
}
|
||||
// Run the test
|
||||
testFunc(mgr, name)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, map[string]string{}, tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTemplate() error = %v", err)
|
||||
return
|
||||
}
|
||||
// TestSentinelTokenReplacement tests the fix for the {{input}} sentinel token bug
|
||||
// This test verifies that when {{input}} is used inside an extension call,
|
||||
// the actual input is passed to the extension, not the sentinel token.
|
||||
func TestSentinelTokenReplacement(t *testing.T) {
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "RECEIVED: $@"
|
||||
`
|
||||
|
||||
// Check that result contains expected string
|
||||
if !strings.Contains(got, tt.wantContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, tt.wantContain)
|
||||
}
|
||||
withTestExtension(t, "echo-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
// Check that result does NOT contain unwanted string
|
||||
if strings.Contains(got, tt.wantNotContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain %q", got, tt.wantNotContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
input string
|
||||
wantContain string
|
||||
wantNotContain string
|
||||
}{
|
||||
{
|
||||
name: "sentinel token with {{input}} in extension value",
|
||||
template: "{{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "test input data",
|
||||
wantContain: "RECEIVED: test input data",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "direct input variable replacement",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Hello World",
|
||||
wantContain: "RECEIVED: Hello World",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
{
|
||||
name: "sentinel with complex input",
|
||||
template: "Result: {{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "What is AI?",
|
||||
wantContain: "RECEIVED: What is AI?",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "multiple words in input",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Multiple word input string",
|
||||
wantContain: "RECEIVED: Multiple word input string",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, map[string]string{}, tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTemplate() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check that result contains expected string
|
||||
if !strings.Contains(got, tt.wantContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, tt.wantContain)
|
||||
}
|
||||
|
||||
// Check that result does NOT contain unwanted string
|
||||
if strings.Contains(got, tt.wantNotContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain %q", got, tt.wantNotContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSentinelInVariableProcessing tests that the sentinel token is handled
|
||||
@@ -180,180 +194,82 @@ func TestSentinelInVariableProcessing(t *testing.T) {
|
||||
// TestExtensionValueWithSentinel specifically tests the extension value
|
||||
// sentinel replacement logic
|
||||
func TestExtensionValueWithSentinel(t *testing.T) {
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
extensionsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extensionsDir, "bin")
|
||||
configsDir := filepath.Join(extensionsDir, "configs")
|
||||
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bin directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(configsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script that outputs the exact arguments it receives
|
||||
scriptPath := filepath.Join(binDir, "arg-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
# Output each argument on a separate line
|
||||
for arg in "$@"; do
|
||||
echo "ARG: $arg"
|
||||
done
|
||||
`
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "arg-test.yaml")
|
||||
configContent := `name: arg-test
|
||||
executable: ` + scriptPath + `
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Argument test extension"
|
||||
version: "1.0.0"
|
||||
withTestExtension(t, "arg-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
operations:
|
||||
test:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
// Test that sentinel token in extension value gets replaced
|
||||
template := "{{ext:arg-test:echo:prefix-__FABRIC_INPUT_SENTINEL_TOKEN__-suffix}}"
|
||||
input := "MYINPUT"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
// The sentinel should be replaced with actual input
|
||||
expectedContain := "ARG: prefix-MYINPUT-suffix"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
// Test that sentinel token in extension value gets replaced
|
||||
template := "{{ext:arg-test:test:prefix-__FABRIC_INPUT_SENTINEL_TOKEN__-suffix}}"
|
||||
input := "MYINPUT"
|
||||
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
// The sentinel should be replaced with actual input
|
||||
expectedContain := "ARG: prefix-MYINPUT-suffix"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
// The sentinel token should NOT appear in output
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain sentinel token", got)
|
||||
}
|
||||
// The sentinel token should NOT appear in output
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain sentinel token", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNestedInputInExtension tests the original bug case:
|
||||
// {{ext:name:op:{{input}}}} should pass the actual input, not the sentinel
|
||||
func TestNestedInputInExtension(t *testing.T) {
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
extensionsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extensionsDir, "bin")
|
||||
configsDir := filepath.Join(extensionsDir, "configs")
|
||||
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bin directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(configsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script
|
||||
scriptPath := filepath.Join(binDir, "nested-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "NESTED_TEST: $*"
|
||||
`
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "nested-test.yaml")
|
||||
configContent := `name: nested-test
|
||||
executable: ` + scriptPath + `
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Nested input test extension"
|
||||
version: "1.0.0"
|
||||
withTestExtension(t, "nested-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
operations:
|
||||
process:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
// This is the bug case: {{input}} nested inside extension call
|
||||
// The template processing should:
|
||||
// 1. Replace {{input}} with sentinel during variable protection
|
||||
// 2. Process the extension, replacing sentinel with actual input
|
||||
// 3. Execute extension with actual input, not sentinel
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
template := "{{ext:nested-test:echo:{{input}}}}"
|
||||
input := "What is Artificial Intelligence"
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
// Verify the actual input was passed, not the sentinel
|
||||
expectedContain := "NESTED_TEST: What is Artificial Intelligence"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
// Verify sentinel token does NOT appear
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() output contains sentinel token (BUG NOT FIXED): %q", got)
|
||||
}
|
||||
|
||||
// This is the bug case: {{input}} nested inside extension call
|
||||
// The template processing should:
|
||||
// 1. Replace {{input}} with sentinel during variable protection
|
||||
// 2. Process the extension, replacing sentinel with actual input
|
||||
// 3. Execute extension with actual input, not sentinel
|
||||
|
||||
template := "{{ext:nested-test:process:{{input}}}}"
|
||||
input := "What is Artificial Intelligence"
|
||||
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify the actual input was passed, not the sentinel
|
||||
expectedContain := "NESTED_TEST: What is Artificial Intelligence"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
// Verify sentinel token does NOT appear
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() output contains sentinel token (BUG NOT FIXED): %q", got)
|
||||
}
|
||||
|
||||
// Verify {{input}} template tag does NOT appear
|
||||
if strings.Contains(got, "{{input}}") {
|
||||
t.Errorf("ApplyTemplate() output contains unresolved {{input}}: %q", got)
|
||||
}
|
||||
// Verify {{input}} template tag does NOT appear
|
||||
if strings.Contains(got, "{{input}}") {
|
||||
t.Errorf("ApplyTemplate() output contains unresolved {{input}}: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user