docs(flows): DoD obligatorio con user-facing surface + abrir issues 0100-0103 (taxonomia, frontmatter migration, dev_console, work dashboard)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:07:03 +02:00
parent 212875ed0d
commit 5d2a14e50a
77 changed files with 4062 additions and 311 deletions
+124 -22
View File
@@ -5,6 +5,8 @@ import (
"net"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
@@ -13,6 +15,9 @@ type ChromeLaunchOpts struct {
// Port es el puerto de remote debugging. Por defecto 9222.
Port int
// UserDataDir es el directorio de perfil de Chrome. Por defecto /tmp/chrome-cdp-profile.
// En WSL2 con chrome.exe, si el valor comienza con /tmp/ o /home/ se traduce
// automaticamente a una ruta Windows via wslpath. Pasar una ruta Windows
// (ej: C:\Users\...) la deja intacta.
UserDataDir string
// Headless activa el modo headless (--headless=new). Por defecto false.
Headless bool
@@ -22,6 +27,53 @@ type ChromeLaunchOpts struct {
ExtraArgs []string
}
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
var reWindowsPath = regexp.MustCompile(`(?i)^[A-Z]:\\`)
// isWSL2 devuelve true si el proceso corre dentro de WSL2.
// Lee /proc/version y busca "microsoft" o "WSL" (case-insensitive).
func isWSL2() bool {
b, err := os.ReadFile("/proc/version")
if err != nil {
return false
}
lower := strings.ToLower(string(b))
return strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
}
// isWindowsExe devuelve true si la ruta del ejecutable corresponde a un .exe
// (chrome.exe en Windows via WSL2: puede ser /mnt/c/... o resolverse en PATH).
func isWindowsExe(path string) bool {
return strings.HasSuffix(strings.ToLower(path), ".exe")
}
// translateUserDataDirForWindows convierte una ruta Linux a ruta Windows via wslpath.
// Ejemplo: "/tmp/foo" -> "C:\Users\lucas\AppData\Local\Temp\foo" (depende del sistema).
// Devuelve error si wslpath no esta disponible.
func translateUserDataDirForWindows(linuxPath string) (string, error) {
out, err := exec.Command("wslpath", "-w", linuxPath).Output()
if err != nil {
return "", fmt.Errorf("wslpath -w %q: %w", linuxPath, err)
}
return strings.TrimSpace(string(out)), nil
}
// defaultWindowsUserDataDir devuelve la ruta Windows del perfil CDP por defecto,
// usando la variable de entorno USERNAME de Windows si esta disponible.
func defaultWindowsUserDataDir() (string, error) {
// Intentar leer el home de Windows via wslpath del home de usuario
// Si falla, usar C:\Users\Public\fn-chrome-cdp-profile como fallback.
user := os.Getenv("USERNAME") // Windows user via WSL env passthrough
if user == "" {
user = os.Getenv("USER")
}
if user == "" {
user = "Public"
}
linuxPath := fmt.Sprintf("/mnt/c/Users/%s/AppData/Local/fn-chrome-cdp-profile", user)
return translateUserDataDirForWindows(linuxPath)
}
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
var chromePaths = []string{
"chrome.exe",
@@ -47,13 +99,13 @@ func findChrome() (string, error) {
}
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
// host puede estar vacio (usa "localhost").
// host puede estar vacio (usa "127.0.0.1").
func waitCDPReady(host string, port int, timeout time.Duration) error {
if host == "" {
host = "localhost"
host = "127.0.0.1"
}
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("%s:%d", host, port)
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
@@ -62,19 +114,22 @@ func waitCDPReady(host string, port int, timeout time.Duration) error {
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("chrome: puerto CDP %s:%d no disponible despues de %s", host, port, timeout)
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", addr, timeout)
}
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
// Retorna el PID del proceso Chrome. Espera hasta 15s a que el puerto CDP este listo.
// Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows.
//
// WSL2 + chrome.exe: cuando se detecta WSL2 y el ejecutable es un .exe,
// - El UserDataDir se traduce automaticamente a ruta Windows via wslpath
// (si esta vacio o comienza con /tmp/ o /home/).
// - Se inyecta --remote-debugging-address=0.0.0.0 para que Chrome sea
// alcanzable desde WSL2 via 127.0.0.1 (el WSL networking reenvía localhost).
func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
if opts.Port == 0 {
opts.Port = 9222
}
if opts.UserDataDir == "" {
opts.UserDataDir = "/tmp/chrome-cdp-profile"
}
chromePath := opts.ChromePath
if chromePath == "" {
@@ -85,9 +140,48 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
}
}
// Detectar si estamos en WSL2 lanzando un exe de Windows
wsl2WindowsMode := isWSL2() && isWindowsExe(chromePath)
// Resolver UserDataDir
userDataDir := opts.UserDataDir
if wsl2WindowsMode {
switch {
case userDataDir == "" ||
strings.HasPrefix(userDataDir, "/tmp/") ||
strings.HasPrefix(userDataDir, "/home/"):
// Traducir a ruta Windows
if userDataDir == "" {
var err error
userDataDir, err = defaultWindowsUserDataDir()
if err != nil {
// Fallback seguro: usar una ruta Windows fija
userDataDir = `C:\Users\Public\fn-chrome-cdp-profile`
}
} else {
translated, err := translateUserDataDirForWindows(userDataDir)
if err != nil {
return 0, fmt.Errorf("chrome: traducir user-data-dir para Windows: %w", err)
}
userDataDir = translated
}
case reWindowsPath.MatchString(userDataDir):
// Ya es una ruta Windows absoluta (C:\...), dejar intacta
default:
// Ruta que no es ni /tmp/ ni /home/ ni Windows absoluta:
// intentar traducir igualmente.
translated, err := translateUserDataDirForWindows(userDataDir)
if err == nil {
userDataDir = translated
}
}
} else if userDataDir == "" {
userDataDir = "/tmp/chrome-cdp-profile"
}
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", opts.Port),
fmt.Sprintf("--user-data-dir=%s", opts.UserDataDir),
fmt.Sprintf("--user-data-dir=%s", userDataDir),
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
@@ -101,12 +195,26 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--disable-translate",
"--metrics-recording-only",
"--safebrowsing-disable-auto-update",
"--remote-allow-origins=*",
}
if opts.Headless {
args = append(args, "--headless=new", "--disable-gpu")
}
// En WSL2+Windows: inyectar --remote-debugging-address=0.0.0.0 si no esta ya presente
hasBindAll := false
for _, a := range opts.ExtraArgs {
if a == "--remote-debugging-address=0.0.0.0" {
hasBindAll = true
break
}
}
if wsl2WindowsMode && !hasBindAll {
args = append(args, "--remote-debugging-address=0.0.0.0")
hasBindAll = true
}
args = append(args, opts.ExtraArgs...)
cmd := exec.Command(chromePath, args...)
@@ -120,20 +228,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
pid := cmd.Process.Pid
// Esperar a que el puerto CDP este listo
// Si Chrome escucha en 0.0.0.0 (ej: WSL2 -> Windows), el caller se encarga del wait
skipWait := false
for _, a := range opts.ExtraArgs {
if a == "--remote-debugging-address=0.0.0.0" {
skipWait = true
break
}
}
if !skipWait {
if err := waitCDPReady("localhost", opts.Port, 15*time.Second); err != nil {
cmd.Process.Kill()
return 0, err
}
// Esperar a que el puerto CDP este listo.
// Siempre esperamos, incluyendo el caso WSL2+Windows donde Chrome escucha en
// 0.0.0.0 — el WSL networking reenvía localhost:9222 → Windows:9222.
// Usamos 127.0.0.1 explicitamente para evitar resolución IPv6 en algunos entornos.
_ = hasBindAll // ya no se usa para skipWait
if err := waitCDPReady("127.0.0.1", opts.Port, 15*time.Second); err != nil {
cmd.Process.Kill()
return 0, err
}
return pid, nil
+39 -9
View File
@@ -3,23 +3,23 @@ name: chrome_launch
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless]
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. En WSL2+chrome.exe, traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0 automaticamente. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net, os, os/exec, time]
imports: [fmt, net, os, os/exec, regexp, strings, time]
params:
- name: opts
desc: "opciones de lanzamiento: Port, UserDataDir, Headless"
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs"
output: "int: PID del proceso Chrome lanzado"
tested: true
tests: ["TestFindChrome", "TestChromeLaunchAndConnect"]
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
test_file_path: "functions/browser/chrome_launch_test.go"
file_path: "functions/browser/chrome_launch.go"
---
@@ -27,10 +27,10 @@ file_path: "functions/browser/chrome_launch.go"
## Ejemplo
```go
// Linux nativo (sin WSL2 o con Linux Chrome)
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
UserDataDir: "/tmp/chrome-cdp",
Headless: true,
Port: 9222,
Headless: true,
})
if err != nil {
log.Fatal(err)
@@ -38,6 +38,32 @@ if err != nil {
defer CdpClose(nil, pid)
```
```go
// WSL2 → chrome.exe Windows: cero configuracion, todo automatico
// ChromeLaunch detecta WSL2+.exe, traduce user-data-dir y bind 0.0.0.0
pid, err := ChromeLaunch(ChromeLaunchOpts{})
if err != nil {
log.Fatal(err)
}
// CDP listo en 127.0.0.1:9222 desde WSL2
conn, err := CdpConnect(9222)
```
## Cuando usarla
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona tanto en Linux nativo como en WSL2 apuntando al chrome.exe de Windows.
## Gotchas
- **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso:
- `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`.
- Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`.
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows).
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo.
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
## Notas
Busca Chrome en este orden:
@@ -49,3 +75,7 @@ Busca Chrome en este orden:
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
El struct `ChromeLaunchOpts` se define en el mismo archivo.
## Capability growth log
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
+67 -8
View File
@@ -2,11 +2,66 @@ package browser
import (
"os"
"regexp"
"strings"
"testing"
"time"
)
// TestIsWSL2 verifica que isWSL2 detecta el entorno correctamente leyendo /proc/version.
func TestIsWSL2(t *testing.T) {
b, err := os.ReadFile("/proc/version")
if err != nil {
t.Skip("/proc/version no disponible (no es Linux)")
}
lower := strings.ToLower(string(b))
expectWSL2 := strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
got := isWSL2()
if got != expectWSL2 {
t.Errorf("isWSL2() = %v, want %v (contenido: %q)", got, expectWSL2, string(b)[:min(120, len(b))])
}
t.Logf("isWSL2() = %v (entorno: %s)", got, string(b)[:min(80, len(b))])
}
// TestTranslateUserDataDirForWindows verifica la traduccion de rutas Linux a Windows.
// Solo corre si wslpath esta disponible (WSL2).
func TestTranslateUserDataDirForWindows(t *testing.T) {
if !isWSL2() {
t.Skip("solo aplica en WSL2")
}
result, err := translateUserDataDirForWindows("/tmp/test-chrome-profile")
if err != nil {
t.Fatalf("translateUserDataDirForWindows: %v", err)
}
// El resultado debe contener backslash (ruta Windows) o empezar con [A-Z]:
reWin := regexp.MustCompile(`(?i)^[A-Z]:\\|\\`)
if !reWin.MatchString(result) {
t.Errorf("resultado no parece ruta Windows: %q", result)
}
t.Logf("translateUserDataDirForWindows('/tmp/test-chrome-profile') = %q", result)
}
// TestIsWindowsExe verifica que isWindowsExe detecta ejecutables .exe.
func TestIsWindowsExe(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", true},
{"chrome.exe", true},
{"CHROME.EXE", true},
{"/usr/bin/google-chrome", false},
{"chromium", false},
{"/mnt/c/Windows/System32/cmd.exe", true},
}
for _, c := range cases {
got := isWindowsExe(c.path)
if got != c.want {
t.Errorf("isWindowsExe(%q) = %v, want %v", c.path, got, c.want)
}
}
}
// TestFindChrome verifica que el ejecutable de Chrome es localizable.
func TestFindChrome(t *testing.T) {
path, err := findChrome()
@@ -20,9 +75,10 @@ func TestFindChrome(t *testing.T) {
}
// TestChromeLaunchAndConnect lanza Chrome, conecta CDP, navega a about:blank y cierra.
// Requiere CHROME_E2E=1 (integración real con Chrome).
func TestChromeLaunchAndConnect(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
if os.Getenv("CHROME_E2E") != "1" {
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
}
// Verificar que Chrome esta disponible
@@ -67,9 +123,10 @@ func TestChromeLaunchAndConnect(t *testing.T) {
}
// TestCdpEvaluate ejecuta JS simple en Chrome y verifica el resultado.
// Requiere CHROME_E2E=1.
func TestCdpEvaluate(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
if os.Getenv("CHROME_E2E") != "1" {
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
}
if _, err := findChrome(); err != nil {
@@ -130,9 +187,10 @@ func TestCdpEvaluate(t *testing.T) {
}
// TestCdpGetHTML obtiene el HTML de about:blank y verifica que contiene elementos basicos.
// Requiere CHROME_E2E=1.
func TestCdpGetHTML(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
if os.Getenv("CHROME_E2E") != "1" {
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
}
if _, err := findChrome(); err != nil {
@@ -178,9 +236,10 @@ func TestCdpGetHTML(t *testing.T) {
}
// TestCdpScreenshot toma un screenshot de about:blank y verifica que se crea el archivo PNG.
// Requiere CHROME_E2E=1.
func TestCdpScreenshot(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
if os.Getenv("CHROME_E2E") != "1" {
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
}
if _, err := findChrome(); err != nil {