feat(infra): auto-commit con 4 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+74
-6
@@ -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...)
|
||||
|
||||
+33
-1
@@ -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 '^(<tests>)$'` 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`
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user