Files
fn_registry/cmd/fn/run_bash_test.go
egutierrez fe784d090f fix(fn-run): propagar stdout/stderr de bash functions library-style
Scripts bash del registry siguen dos patrones:
- Con guarda BASH_SOURCE[0]==$0: se auto-invocan al ejecutar directamente
- Library-style (sin guarda): definen una función <basename>() pero no la
  llaman al nivel top-level → bash <script> args produce silencio total

El dispatcher en buildBashCommand detecta ahora tres casos:
1. Tiene guarda BASH_SOURCE[0]==$0 → ejecutar directamente (sin cambio)
2. Library-style con función <basename>() → source + llamada explícita:
   bash -c 'source "$1"; shift; fn_name "$@"' -- <script> [args...]
3. Pipeline top-level (sin función ni guarda) → ejecutar directamente

También corrige scan_secrets_in_dirty.sh y git_hook_audit_app_drift.sh
para aceptar worktrees git (donde .git es un archivo, no un directorio).

Añade bashFunctionName() helper y 4 tests unitarios/integración.
Fix reportado en issue 0077 con gradle_unit_test como caso canario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:52:23 +02:00

182 lines
4.4 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestBashFunctionName verifies detection of library-style bash scripts
// that define a function matching the file basename.
func TestBashFunctionName(t *testing.T) {
cases := []struct {
name string
filename string
content string
wantFn string
}{
{
name: "library-style: defines matching function",
filename: "gradle_unit_test.sh",
content: `#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/gradle_run.sh"
gradle_unit_test() {
local project_dir="$1"
echo "running tests in $project_dir"
}
`,
wantFn: "gradle_unit_test",
},
{
name: "library-style: defines matching function with space before parens",
filename: "port_kill.sh",
content: `#!/usr/bin/env bash
port_kill () {
echo "killing port $1"
}
`,
wantFn: "port_kill",
},
{
name: "top-level pipeline: no matching function",
filename: "propose_capability_groups.sh",
content: `#!/usr/bin/env bash
# inline pipeline logic
find_root() { echo "root"; }
while [[ $# -gt 0 ]]; do
shift
done
`,
wantFn: "",
},
{
name: "self-executing (has guard): no separate function detection needed",
filename: "rsync_deploy.sh",
content: `#!/usr/bin/env bash
rsync_deploy() {
echo "deploying"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
rsync_deploy "$@"
fi
`,
// bashFunctionName should still return the function name — the
// caller (buildBashCommand) decides which path to take based on
// the guard, not this helper.
wantFn: "rsync_deploy",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := bashFunctionName("/tmp/"+tc.filename, tc.content)
if got != tc.wantFn {
t.Errorf("bashFunctionName(%q) = %q, want %q", tc.filename, got, tc.wantFn)
}
})
}
}
// TestBuildBashCommand_LibraryStyle verifies that a library-style bash script
// (defines a function but has no self-invocation guard) is run by sourcing
// the script and calling the function — producing real stdout output.
func TestBuildBashCommand_LibraryStyle(t *testing.T) {
dir := t.TempDir()
// Write a library-style script: defines a function, no guard.
scriptPath := filepath.Join(dir, "say_hello.sh")
scriptContent := `#!/usr/bin/env bash
say_hello() {
echo "hello from $1"
}
`
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatal(err)
}
cmd, err := buildBashCommand(scriptPath, []string{"world"})
if err != nil {
t.Fatalf("buildBashCommand error: %v", err)
}
out, err := cmd.Output()
if err != nil {
t.Fatalf("cmd.Output() error: %v", err)
}
got := strings.TrimSpace(string(out))
want := "hello from world"
if got != want {
t.Errorf("output = %q, want %q", got, want)
}
}
// TestBuildBashCommand_SelfGuard verifies that a script with the BASH_SOURCE
// guard is run directly (not via source+call) and still produces output.
func TestBuildBashCommand_SelfGuard(t *testing.T) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "say_hi.sh")
scriptContent := `#!/usr/bin/env bash
say_hi() {
echo "hi from $1"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
say_hi "$@"
fi
`
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatal(err)
}
cmd, err := buildBashCommand(scriptPath, []string{"guard"})
if err != nil {
t.Fatalf("buildBashCommand error: %v", err)
}
out, err := cmd.Output()
if err != nil {
t.Fatalf("cmd.Output() error: %v", err)
}
got := strings.TrimSpace(string(out))
want := "hi from guard"
if got != want {
t.Errorf("output = %q, want %q", got, want)
}
}
// TestBuildBashCommand_TopLevelPipeline verifies that a pipeline script that
// runs top-level code directly (no function, no guard) produces output.
func TestBuildBashCommand_TopLevelPipeline(t *testing.T) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "my_pipeline.sh")
scriptContent := `#!/usr/bin/env bash
echo "pipeline ran with $# args: $@"
`
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatal(err)
}
cmd, err := buildBashCommand(scriptPath, []string{"a", "b"})
if err != nil {
t.Fatalf("buildBashCommand error: %v", err)
}
out, err := cmd.Output()
if err != nil {
t.Fatalf("cmd.Output() error: %v", err)
}
got := strings.TrimSpace(string(out))
want := "pipeline ran with 2 args: a b"
if got != want {
t.Errorf("output = %q, want %q", got, want)
}
}