From fa09ff98664102ced0b8b9c3b9f260da717f4058 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 3 Jun 2026 16:44:23 +0200 Subject: [PATCH] feat(infra): auto-commit con 4 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/fn/run.go | 80 +++++++++++++++++-- .../0167-fn-run-library-go-paquete-entero.md | 34 +++++++- functions/infra/docker_container_exec_test.go | 12 ++- functions/infra/ssh_tunnel_test.go | 20 ++++- 4 files changed, 136 insertions(+), 10 deletions(-) rename dev/issues/{ => completed}/0167-fn-run-library-go-paquete-entero.md (75%) diff --git a/cmd/fn/run.go b/cmd/fn/run.go index e15dab39..1e360239 100644 --- a/cmd/fn/run.go +++ b/cmd/fn/run.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "database/sql" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -56,8 +58,19 @@ func cmdRun(args []string) { os.Exit(1) } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + // When fn run executes a scoped `go test -run`, mirror its output into a + // buffer so we can detect a "no tests to run" result — which go test reports + // with exit 0 and would otherwise be a silent false-green (e.g. the extracted + // unit_tests names drifted from the code). See issue 0167. + guardGoTest := fn.Lang == "go" && isGoTestRun(cmd) + var outBuf bytes.Buffer + if guardGoTest { + cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &outBuf) + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } cmd.Stdin = os.Stdin fmt.Fprintf(os.Stderr, "[fn run] %s (%s/%s) %s\n", fn.ID, fn.Lang, fn.Kind, strings.Join(passArgs, " ")) @@ -66,6 +79,13 @@ func cmdRun(args []string) { runErr := cmd.Run() durationMs := time.Since(t0).Milliseconds() + // A scoped go test that matched zero tests is a false-green: treat as failure. + if guardGoTest && runErr == nil && strings.Contains(outBuf.String(), "no tests to run") { + fmt.Fprintf(os.Stderr, "\n[fn run] error: -run no encontro ningun test para %s — los nombres de test extraidos no existen en el codigo; corre 'fn index'\n", fn.ID) + logFnRunTelemetry(registryRoot, fn.ID, durationMs, false, "no_tests_run") + os.Exit(1) + } + exitCode := 0 errClass := "" if runErr != nil { @@ -140,7 +160,7 @@ func resolveFunction(db *registry.DB, idOrName string) (*registry.Function, erro func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) { switch fn.Lang { case "go": - return buildGoCommand(fn, registryRoot, absPath, args) + return buildGoCommand(fn, db, registryRoot, absPath, args) case "py": return buildPyRunnerCommand(fn, db, registryRoot, args) case "bash": @@ -154,7 +174,7 @@ func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath } } -func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) { +func buildGoCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) { dir := filepath.Dir(absPath) env := append(os.Environ(), "CGO_ENABLED=1") @@ -168,13 +188,23 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args [] return cmd, nil } - // Library code: if it has tests → go test + // Library code with tests → go test, but scoped to THIS function's tests via + // -run, so a flaky test of a sibling function in the same package does not + // break `fn run`. Test names come from the indexer-extracted unit_tests table + // (parsed from the real .go, reliable), never the .md frontmatter (can drift). + // The cmdRun guard fails the run if -run matches zero tests, preventing a + // silent "no tests to run" false-green. See issue 0167. if fn.Tested && fn.TestFilePath != "" { testAbs := filepath.Join(registryRoot, fn.TestFilePath) if _, err := os.Stat(testAbs); err == nil { relPkg, _ := filepath.Rel(registryRoot, dir) pkgPath := "./" + filepath.ToSlash(relPkg) - cmdArgs := append([]string{"test", "-v", "-count=1", "-tags", "fts5", pkgPath}, args...) + cmdArgs := []string{"test", "-v", "-count=1", "-tags", "fts5"} + if names := goTestNames(db, fn.ID); len(names) > 0 { + cmdArgs = append(cmdArgs, "-run", "^("+strings.Join(names, "|")+")$") + } + cmdArgs = append(cmdArgs, pkgPath) + cmdArgs = append(cmdArgs, args...) cmd := exec.Command("go", cmdArgs...) cmd.Dir = registryRoot cmd.Env = env @@ -193,6 +223,44 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args [] return cmd, nil } +// goTestNames returns the top-level Go test function names registered for fn in +// the indexer-extracted unit_tests table. These drive `go test -run` so that +// `fn run` only executes the function's own tests, isolating it from flaky tests +// of sibling functions in the same package. Returns nil if none are known (db is +// nil, lookup fails, or no tests extracted), in which case the caller falls back +// to running the whole package. +func goTestNames(db *registry.DB, functionID string) []string { + if db == nil { + return nil + } + uts, err := db.GetUnitTestsByFunction(functionID) + if err != nil { + return nil + } + var names []string + for _, ut := range uts { + if ut.Name != "" { + names = append(names, ut.Name) + } + } + return names +} + +// isGoTestRun reports whether cmd is a `go test ... -run ...` invocation, used to +// enable the zero-tests-matched guard in cmdRun. +func isGoTestRun(cmd *exec.Cmd) bool { + var hasTest, hasRun bool + for _, a := range cmd.Args { + switch a { + case "test": + hasTest = true + case "-run": + hasRun = true + } + } + return hasTest && hasRun +} + func buildBashCommand(absPath string, args []string) (*exec.Cmd, error) { cmdArgs := append([]string{absPath}, args...) diff --git a/dev/issues/0167-fn-run-library-go-paquete-entero.md b/dev/issues/completed/0167-fn-run-library-go-paquete-entero.md similarity index 75% rename from dev/issues/0167-fn-run-library-go-paquete-entero.md rename to dev/issues/completed/0167-fn-run-library-go-paquete-entero.md index e88fe627..4124d289 100644 --- a/dev/issues/0167-fn-run-library-go-paquete-entero.md +++ b/dev/issues/completed/0167-fn-run-library-go-paquete-entero.md @@ -1,7 +1,7 @@ --- id: "0167" title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)" -status: pendiente +status: completado type: enhancement domain: - registry-quality @@ -120,6 +120,38 @@ conceptualmente pero rompe comportamiento documentado; evaluar si ese comportami | Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) | | Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas | +## Resolución (2026-06-03) + +Implementada la combinación **C + B** recomendada. + +### C — Tests impuros saneados (`functions/infra/`) +- `ssh_tunnel_test.go`: el puerto fijo `19876` pasa a **puerto efímero** (`freeTCPPort` pide `:0` al kernel). Elimina el `bind: address already in use` bajo concurrencia. +- `docker_container_exec_test.go`: el socket Unix deja de colgar de `t.TempDir()` (path largo con el nombre del subtest) y usa un **directorio corto** bajo `/tmp` (`os.MkdirTemp("/tmp", "dk")` + cleanup). Elimina el `bind: invalid argument` por exceder los ~108 bytes de `sun_path`. +- Verificado: `go test -run 'TestSSHTunnelOpenClose|TestDockerContainerExec' -count=5 ./functions/infra/` → `ok` (5×, determinista). + +### B — `fn run` acota los tests a la función (`cmd/fn/run.go`) +- Para una library Go function con tests, el dispatcher ahora añade + `-run '^()$'` con los nombres **extraídos por el indexer** (`unit_tests`, + vía `db.GetUnitTestsByFunction`), no los del frontmatter `.md` (que pueden driftar). + Así `fn run` ejecuta solo los tests de esa función, aislándola de tests flaky de + funciones vecinas del mismo paquete. Si no hay nombres extraídos, cae al paquete + entero (comportamiento previo). +- **Guard anti-falso-verde**: `cmdRun` refleja el output de un `go test -run` a un + buffer; si go test reporta `no tests to run` (que sale con exit 0), el run se trata + como **fallo** (exit 1 + mensaje pidiendo `fn index`). Evita que un drift de nombres + produzca un verde silencioso. + +### Evidencia (DoD) + +| Escenario | Resultado | +|---|---| +| Golden: `fn run find_unused_functions_go_infra` | Corre solo sus 2 tests (`TestFindUnusedFunctions_*`) en 0.06s, exit 0. No toca SSH/Docker. | +| Edge concurrente: 2 `fn run` del paquete `infra` en paralelo | Ambos exit 0, sin colisión de puerto. | +| Error/drift: `unit_tests` con nombre inexistente | `go test` da `[no tests to run]`; el guard lo intercepta → exit 1 con mensaje. NO falso-verde. | +| Tests saneados 5× | `ok` determinista. | + +`go vet ./cmd/fn/` y `go test ./cmd/fn/` verdes tras los cambios. + ## Notas - Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go` diff --git a/functions/infra/docker_container_exec_test.go b/functions/infra/docker_container_exec_test.go index 384baff2..d7b5829c 100644 --- a/functions/infra/docker_container_exec_test.go +++ b/functions/infra/docker_container_exec_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net" "net/http" + "os" "strings" "testing" "time" @@ -15,7 +16,16 @@ import ( func newUnixDockerServer(t *testing.T, handler http.Handler) (socketPath string) { t.Helper() - socketPath = t.TempDir() + "/docker_exec_test.sock" + // Los sockets Unix tienen un limite de ~108 bytes en sun_path (Linux). + // t.TempDir() incluye el nombre del subtest (largo) y un TMPDIR que puede + // ser largo, excediendo el limite -> "bind: invalid argument". Crear un + // directorio corto directamente bajo /tmp. + dir, err := os.MkdirTemp("/tmp", "dk") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + socketPath = dir + "/d.sock" ln, err := net.Listen("unix", socketPath) if err != nil { t.Fatalf("listen unix %s: %v", socketPath, err) diff --git a/functions/infra/ssh_tunnel_test.go b/functions/infra/ssh_tunnel_test.go index b20da2a3..f1901ff9 100644 --- a/functions/infra/ssh_tunnel_test.go +++ b/functions/infra/ssh_tunnel_test.go @@ -11,8 +11,10 @@ func TestSSHTunnelOpenClose(t *testing.T) { conn := skipIfNoSSH(t) t.Run("abre tunel y lo cierra", func(t *testing.T) { - // Usar puerto alto aleatorio para evitar conflictos - localPort := 19876 + // Puerto efimero libre: un puerto fijo daba "address already in use" + // cuando el paquete corre con -count o concurrentemente con otra + // ejecucion de `go test` del mismo paquete. + localPort := freeTCPPort(t) // Tunel a localhost:22 del remoto (el propio sshd) pid, err := SSHTunnelOpen(conn, localPort, "localhost", 22) if err != nil { @@ -47,3 +49,17 @@ func TestSSHTunnelOpenClose(t *testing.T) { } }) } + +// freeTCPPort asks the kernel for a free TCP port on loopback by binding to +// port 0, reading the assigned port, and releasing it. A small race window +// exists before the caller reuses the port, but it avoids the hard collisions +// of a fixed port across concurrent or repeated test runs. +func freeTCPPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freeTCPPort: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +}