commit 822cbbd45071d795c909b1bd3f4ec27adce0bcea Author: fn-registry agent Date: Sat May 9 18:11:21 2026 +0200 chore: sync from fn-registry agent diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..505f777 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,20 @@ +# navegator_dashboard — Windows-only por diseño. +# Linux: skip silenciosamente para que cpp/build/ no falle. + +if(NOT WIN32) + message(STATUS "navegator_dashboard: skipping (Windows-only).") + return() +endif() + +add_imgui_app(navegator_dashboard + main.cpp + chrome_scanner.cpp + chrome_launcher.cpp + local_api.cpp + panels.cpp +) + +target_include_directories(navegator_dashboard PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(navegator_dashboard PRIVATE ws2_32) + +set_target_properties(navegator_dashboard PROPERTIES WIN32_EXECUTABLE TRUE) diff --git a/app.md b/app.md new file mode 100644 index 0000000..c1556d2 --- /dev/null +++ b/app.md @@ -0,0 +1,94 @@ +--- +name: navegator_dashboard +lang: cpp +domain: tools +description: "Cuadro de mandos para gestionar instancias Chrome con remote debugging. Lista navegadores corriendo (visibles + headless), permite lanzar/matar perfiles, inspeccionar pestañas, ejecutar JS, ver peticiones de red. Puente WSL→Windows que centraliza el control que hoy hacemos por scripts dispersos." +tags: [imgui, browser, cdp, dashboard, windows, navegator] +uses_functions: [] +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "projects/navegator/apps/navegator_dashboard" +repo_url: "" + +e2e_checks: + - id: build_windows + cmd: "cd /home/lucas/fn_registry && ./fn run compile_cpp_app navegator_dashboard" + timeout_s: 600 + - id: exe_present + cmd: "test -f /mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe" +--- + +## Proposito + +Centralizar el control de **todas** las instancias de Chrome con `--remote-debugging-port` que se lancen en el equipo. Sustituye los scripts sueltos `start.sh`/`stop.sh`/`nav.sh` de `projects/navegator/scripts/` por una UI unificada + API HTTP local. + +Casos de uso: + +- Ver de un vistazo cuantos Chrome estan vivos, en que puerto, con que profile, headless o no. +- Lanzar/matar perfiles desde la UI sin tocar terminal. +- Listar pestañas de cada instancia, navegar, cerrar, capturar HTML/screenshot. +- Inspeccionar peticiones de red en vivo (CDP `Network.*` events). +- Exponer todo eso como API HTTP local para que scripts/agentes/cdp-cli/graph_explorer hablen con un solo endpoint en vez de cada uno re-implementar el control. + +## Arquitectura + +``` +┌───────────── navegator_dashboard.exe (Windows) ─────────────┐ +│ │ +│ UI ImGui (panels) HTTP API (127.0.0.1:1923X) │ +│ ├── Browsers GET /browsers │ +│ ├── Tabs POST /browser/spawn │ +│ ├── Tab Detail POST /browser/{port}/kill │ +│ └── Network GET /browser/{port}/tabs │ +│ POST /browser/{port}/navigate │ +│ Worker threads: GET /browser/{port}/tab/{id}/html│ +│ ├── ChromeScanner GET /browser/{port}/network/log │ +│ ├── CdpHttpClient ... │ +│ └── CdpWsStream │ +└──────────────────────────────────────────────────────────────┘ + ↓ scan/spawn/kill ↓ /json + ws +┌─────────── chrome.exe N instancias (Windows) ───────────────┐ +│ perfil=default port=19222 [visible] │ +│ perfil=osint port=19223 [visible] │ +│ perfil=headless_scrape port=19224 [headless] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Panels (v0 → v1) + +| Panel | v0 | v1 | v2 | +|---|---|---|---| +| **Browsers** | scan + spawn + kill | filtro/search | grupos (perfiles favoritos) | +| **Tabs** | stub | listar + navigate + close | drag&drop entre instancias | +| **Tab Detail** | stub | HTML preview + screenshot + JS REPL | live mirror de pestaña (CDP screencast) | +| **Network** | stub | request log + headers + body | timeline + waterfall + filtro | + +## Stack + +- `fn::run_app` (cpp/framework/app_base.h) — shell estandar (PATTERNS.md). +- ImGui + ImPlot (chart Network). +- ChromeScanner: `Get-CimInstance Win32_Process` invocado con `_popen`. Itera ~1 Hz. +- ChromeLauncher: `CreateProcess` Windows. Argumentos derivados de `chrome_launch_go_browser` (registry). +- CDP HTTP: GET `/json/version`, `/json` por puerto via WinSock raw o `WinHttp`. v1. +- CDP WS: handshake RFC 6455 + framing manual (mismo esquema que `cdp_conn.go`). v1. +- HTTP API local: `cpp-httplib` (header-only) bind 127.0.0.1. v0 stub, funcional v1. + +## Decisiones consciente (cpp_apps.md §9) + +- `viewports`: `true` (default) — la app puede arrastrar paneles fuera del main. +- `init_gl_loader`: `false` — solo ImGui, sin OpenGL custom. +- `local_files/`: la app guarda su `app_settings.ini` + `navegator_dashboard.db` con historial de instancias y perfiles favoritos. +- Modo CLI: `--api-only --port N` para correr sin UI (daemon puro). v1+. + +## Plataforma + +- **Windows-only**. La app vive en WSL como codigo pero solo se compila como binario Windows (mingw-w64). En Linux, `CMakeLists.txt` hace `return()` antes de `add_imgui_app` para que `cpp/build/` no falle. +- Razon: 90% de la logica usa Win32 (CreateProcess, WMI, taskkill). Linux Chrome es secundario y ya esta cubierto por `cdp-cli` directo. + +## Roadmap + +- v0 (este commit): skeleton + Browsers panel funcional + 3 stubs. +- v1: CDP HTTP/WS in-process + Tabs + Tab Detail + Network panel. +- v2: HTTP API local + integracion con `cdp-cli` (cdp-cli puede delegar al dashboard si esta vivo). +- v3: streaming live de pestañas (CDP `Page.startScreencast`) — arquitectura ya prevista en issue 0038. diff --git a/chrome_launcher.cpp b/chrome_launcher.cpp new file mode 100644 index 0000000..9529afa --- /dev/null +++ b/chrome_launcher.cpp @@ -0,0 +1,132 @@ +#include "chrome_launcher.h" + +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#endif + +namespace navegator { + +namespace { + +#ifdef _WIN32 +const char* kChromePaths[] = { + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", +}; + +bool file_exists_w(const char* path) { + DWORD attrs = GetFileAttributesA(path); + return (attrs != INVALID_FILE_ATTRIBUTES) && !(attrs & FILE_ATTRIBUTE_DIRECTORY); +} + +std::string find_chrome() { + for (const char* p : kChromePaths) { + if (file_exists_w(p)) return p; + } + return ""; +} + +// Quote para argv en CreateProcess (rules de MSVCR / Chromium): +// envolver en " y escapar las " internas con \" y los \ que preceden a " con \\. +std::string quote_arg(const std::string& a) { + if (a.empty()) return "\"\""; + bool needs = a.find_first_of(" \t\"") != std::string::npos; + if (!needs) return a; + std::string out = "\""; + int backslashes = 0; + for (char c : a) { + if (c == '\\') { + ++backslashes; + out += c; + } else if (c == '"') { + // Duplicar las \\ acumuladas + escapar la " + for (int i = 0; i < backslashes; ++i) out += '\\'; + backslashes = 0; + out += "\\\""; + } else { + backslashes = 0; + out += c; + } + } + // Cerrar: duplicar \\ pendientes. + for (int i = 0; i < backslashes; ++i) out += '\\'; + out += '"'; + return out; +} +#endif + +} // namespace + +LaunchResult launch_chrome(const LaunchOpts& opts) { + LaunchResult r; +#ifndef _WIN32 + (void)opts; + r.error = "launch_chrome: solo Windows en v0"; + return r; +#else + std::string chrome = opts.chrome_path.empty() ? find_chrome() : opts.chrome_path; + if (chrome.empty()) { + r.error = "chrome.exe no encontrado en Program Files"; + return r; + } + if (opts.user_data_dir.empty()) { + r.error = "user_data_dir obligatorio para aislar perfil"; + return r; + } + + // Crear el directorio si no existe (ignoramos errores, Chrome se queja si no puede). + CreateDirectoryA(opts.user_data_dir.c_str(), nullptr); + + char port_buf[32]; + std::snprintf(port_buf, sizeof(port_buf), "--remote-debugging-port=%d", opts.port); + + std::string cmd; + cmd = quote_arg(chrome); + cmd += " "; + cmd += quote_arg(port_buf); + cmd += " --remote-allow-origins=*"; + cmd += " "; + cmd += quote_arg(std::string("--user-data-dir=") + opts.user_data_dir); + cmd += " --no-first-run --no-default-browser-check"; + if (opts.headless) { + cmd += " --headless=new --disable-gpu"; + } + if (!opts.start_url.empty()) { + cmd += " "; + cmd += quote_arg(opts.start_url); + } else { + cmd += " about:blank"; + } + + STARTUPINFOA si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + + // CreateProcess modifica el commandline buffer — copia mutable. + std::string mutable_cmd = cmd; + BOOL ok = CreateProcessA( + nullptr, + mutable_cmd.data(), + nullptr, nullptr, FALSE, + CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, + nullptr, nullptr, &si, &pi); + if (!ok) { + DWORD err = GetLastError(); + char buf[64]; + std::snprintf(buf, sizeof(buf), "CreateProcess failed (err=%lu)", (unsigned long)err); + r.error = buf; + return r; + } + r.ok = true; + r.pid = (uint32_t)pi.dwProcessId; + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return r; +#endif +} + +} // namespace navegator diff --git a/chrome_launcher.h b/chrome_launcher.h new file mode 100644 index 0000000..b9b46e4 --- /dev/null +++ b/chrome_launcher.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace navegator { + +struct LaunchOpts { + std::string chrome_path; // por defecto auto-detect + int port = 19222; // remote-debugging-port + std::string user_data_dir; // ruta Windows (C:\...) — obligatorio para aislar + bool headless = false; + std::string start_url; // URL inicial; vacio = about:blank +}; + +struct LaunchResult { + bool ok = false; + uint32_t pid = 0; + std::string error; // descripcion humana si ok=false +}; + +// Lanza chrome.exe con los flags de remote debugging que YA descubrimos +// que funcionan en Chrome 147 (ver projects/navegator/notes/2026-05-09-...): +// --remote-debugging-port= +// --remote-allow-origins=* [obligatorio Chrome 111+] +// --user-data-dir= +// --no-first-run --no-default-browser-check +// + flags si headless. +// +// NO usa --remote-debugging-address=0.0.0.0 (rompe bind en Chrome 147). +LaunchResult launch_chrome(const LaunchOpts& opts); + +} // namespace navegator diff --git a/chrome_scanner.cpp b/chrome_scanner.cpp new file mode 100644 index 0000000..bd1763f --- /dev/null +++ b/chrome_scanner.cpp @@ -0,0 +1,203 @@ +#include "chrome_scanner.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#endif + +namespace navegator { + +namespace { + +#ifdef _WIN32 +// Ejecuta un comando con CreateProcess + pipe stdout, OCULTANDO la consola. +// _popen siempre muestra cmd.exe en GUI apps WIN32_EXECUTABLE — para auto-rescan +// cada 2s eso parpadea sin parar. CREATE_NO_WINDOW evita el flicker. +std::string run_capture(const std::string& cmd) { + HANDLE r_pipe = nullptr; + HANDLE w_pipe = nullptr; + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + if (!CreatePipe(&r_pipe, &w_pipe, &sa, 0)) return ""; + SetHandleInformation(r_pipe, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + si.hStdOutput = w_pipe; + si.hStdError = w_pipe; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + std::string mutable_cmd = cmd; + BOOL ok = CreateProcessA( + nullptr, + mutable_cmd.data(), + nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, + nullptr, nullptr, &si, &pi); + + CloseHandle(w_pipe); // padre no escribe + + if (!ok) { + CloseHandle(r_pipe); + return ""; + } + + std::string out; + char buf[4096]; + DWORD nread = 0; + while (ReadFile(r_pipe, buf, sizeof(buf), &nread, nullptr) && nread > 0) { + out.append(buf, nread); + } + CloseHandle(r_pipe); + + WaitForSingleObject(pi.hProcess, 30000); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return out; +} +#else +std::string run_capture(const std::string& cmd) { + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) return ""; + std::string out; + char buf[4096]; + while (fgets(buf, sizeof(buf), pipe)) out.append(buf); + pclose(pipe); + return out; +} +#endif + +// Extrae el valor de --flag=VALUE o --flag VALUE de una commandline. +// Devuelve cadena vacia si no se encuentra. +std::string extract_flag(const std::string& cmd, const std::string& flag) { + // Busca "--flag=" primero. + std::string needle_eq = flag + "="; + auto p = cmd.find(needle_eq); + if (p != std::string::npos) { + size_t start = p + needle_eq.size(); + // Si empieza con comilla, leer hasta la siguiente. + char quote = 0; + if (start < cmd.size() && (cmd[start] == '"' || cmd[start] == '\'')) { + quote = cmd[start]; + ++start; + } + size_t end = start; + while (end < cmd.size()) { + if (quote) { + if (cmd[end] == quote) break; + } else { + if (cmd[end] == ' ') break; + } + ++end; + } + return cmd.substr(start, end - start); + } + // Fallback: "--flag VALUE". + auto p2 = cmd.find(flag + " "); + if (p2 != std::string::npos) { + size_t start = p2 + flag.size() + 1; + size_t end = cmd.find(' ', start); + if (end == std::string::npos) end = cmd.size(); + return cmd.substr(start, end - start); + } + return ""; +} + +std::string basename_of_path(const std::string& path) { + size_t s1 = path.find_last_of('\\'); + size_t s2 = path.find_last_of('/'); + size_t s = std::string::npos; + if (s1 != std::string::npos) s = s1; + if (s2 != std::string::npos && (s == std::string::npos || s2 > s)) s = s2; + if (s == std::string::npos) return path; + return path.substr(s + 1); +} + +} // namespace + +std::vector scan_chrome_instances() { +#ifndef _WIN32 + return {}; +#else + // Format: por cada chrome.exe que tenga --remote-debugging-port, escribimos + // dos lineas: "PID:" y "CMD:", separadas por "---". + // Quoting: el CommandLine puede tener caracteres raros, pero lo escribimos + // tal cual y parseamos por prefijo. Sin JSON => sin dependencias. + // Filtrar SOLO el master process: tiene --remote-debugging-port y NO tiene + // --type= (los renderers/gpu-process/utility heredan el cmdline del padre + // pero llevan ademas --type=renderer / --type=gpu-process / etc). + const char* ps_script = + "powershell.exe -NoProfile -Command \"" + "Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" " + "| Where-Object { $_.CommandLine -like '*--remote-debugging-port=*' " + " -and $_.CommandLine -notlike '*--type=*' } " + "| ForEach-Object { " + " Write-Output '---'; " + " Write-Output ('PID:' + $_.ProcessId); " + " Write-Output ('CMD:' + $_.CommandLine) " + "}\""; + std::string out = run_capture(ps_script); + + std::vector result; + std::istringstream ss(out); + std::string line; + ChromeInstance cur; + bool have_cur = false; + while (std::getline(ss, line)) { + // Trim CR si viene con \r\n. + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (line == "---") { + if (have_cur && cur.port > 0) result.push_back(cur); + cur = ChromeInstance{}; + have_cur = true; + } else if (line.rfind("PID:", 0) == 0) { + try { cur.pid = (uint32_t)std::stoul(line.substr(4)); } catch (...) { cur.pid = 0; } + } else if (line.rfind("CMD:", 0) == 0) { + cur.command_line = line.substr(4); + std::string port_str = extract_flag(cur.command_line, "--remote-debugging-port"); + try { cur.port = std::stoi(port_str); } catch (...) { cur.port = 0; } + cur.user_data_dir = extract_flag(cur.command_line, "--user-data-dir"); + cur.profile_name = basename_of_path(cur.user_data_dir); + cur.headless = + cur.command_line.find("--headless") != std::string::npos; + } + } + if (have_cur && cur.port > 0) result.push_back(cur); + return result; +#endif +} + +int kill_chromes_by_userdata(const std::string& user_data_dir_substr) { +#ifndef _WIN32 + (void)user_data_dir_substr; + return -1; +#else + if (user_data_dir_substr.empty()) return -1; + // Escapamos comillas simples duplicandolas (PowerShell single-quote rule). + std::string esc; + esc.reserve(user_data_dir_substr.size() * 2); + for (char c : user_data_dir_substr) { + if (c == '\'') esc += "''"; + else esc += c; + } + std::string cmd = + "powershell.exe -NoProfile -Command \"" + "$n = (Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" " + "| Where-Object { $_.CommandLine -like '*" + esc + "*' } " + "| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; 1 } " + "| Measure-Object).Count; Write-Output $n\""; + std::string out = run_capture(cmd); + try { return std::stoi(out); } catch (...) { return -1; } +#endif +} + +} // namespace navegator diff --git a/chrome_scanner.h b/chrome_scanner.h new file mode 100644 index 0000000..5a1872c --- /dev/null +++ b/chrome_scanner.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace navegator { + +// Una instancia de chrome.exe detectada con --remote-debugging-port. +struct ChromeInstance { + uint32_t pid = 0; + int port = 0; // valor de --remote-debugging-port, 0 si no se pudo parsear + std::string user_data_dir; // valor de --user-data-dir (Windows path), vacio si no aplica + std::string profile_name; // basename de user_data_dir, util como label + bool headless = false; // si tiene --headless o --headless=new + std::string command_line; // crudo, para diagnostico +}; + +// Escanea procesos chrome.exe del sistema y devuelve solo los que tienen +// --remote-debugging-port. Implementacion v0: invoca PowerShell con +// Get-CimInstance. Es lenta (~500ms) — el panel debe llamar en thread aparte. +// +// Si la consulta falla, retorna lista vacia (no lanza). Linux: stub vacio. +std::vector scan_chrome_instances(); + +// Mata todos los chrome.exe cuya commandline contenga 'user_data_dir_substr' +// como substring. Util para cerrar perfiles aislados sin afectar al Chrome +// normal del usuario. Devuelve numero de procesos matados o -1 en error. +int kill_chromes_by_userdata(const std::string& user_data_dir_substr); + +} // namespace navegator diff --git a/local_api.cpp b/local_api.cpp new file mode 100644 index 0000000..90f21a7 --- /dev/null +++ b/local_api.cpp @@ -0,0 +1,355 @@ +// local_api.cpp — Servidor HTTP minimo (WinSock) para que scripts/agentes +// hablen con el dashboard. Sin dependencias externas: parser de request + +// dispatch + JSON escapado a mano. ~250 LoC. + +#include "local_api.h" +#include "chrome_scanner.h" +#include "chrome_launcher.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +# include +# pragma comment(lib, "Ws2_32.lib") +#endif + +namespace navegator { + +std::atomic g_api_running{false}; +std::atomic g_api_port{0}; +std::atomic g_api_request_count{0}; + +namespace { + +// ---------- JSON helpers ---------- +std::string json_escape(const std::string& s) { + std::string out; out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char)c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)c); + out += buf; + } else { + out += c; + } + } + } + return out; +} + +std::string instance_to_json(const ChromeInstance& i) { + std::ostringstream os; + os << "{\"pid\":" << i.pid + << ",\"port\":" << i.port + << ",\"profile\":\"" << json_escape(i.profile_name) << "\"" + << ",\"user_data_dir\":\"" << json_escape(i.user_data_dir) << "\"" + << ",\"headless\":" << (i.headless ? "true" : "false") + << "}"; + return os.str(); +} + +// ---------- URL decoder + query parser ---------- +std::string url_decode(const std::string& s) { + std::string out; out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '+') { + out += ' '; + } else if (s[i] == '%' && i + 2 < s.size()) { + int hi = 0, lo = 0; + auto hex = [](char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return 0; + }; + hi = hex(s[i + 1]); + lo = hex(s[i + 2]); + out += (char)((hi << 4) | lo); + i += 2; + } else { + out += s[i]; + } + } + return out; +} + +std::map parse_query(const std::string& q) { + std::map m; + size_t start = 0; + while (start < q.size()) { + size_t amp = q.find('&', start); + std::string pair = q.substr(start, amp == std::string::npos ? std::string::npos : amp - start); + size_t eq = pair.find('='); + if (eq != std::string::npos) { + m[url_decode(pair.substr(0, eq))] = url_decode(pair.substr(eq + 1)); + } else if (!pair.empty()) { + m[url_decode(pair)] = ""; + } + if (amp == std::string::npos) break; + start = amp + 1; + } + return m; +} + +// ---------- Default user_data_dir resolver ---------- +std::string default_user_data_dir(const std::string& profile) { +#ifdef _WIN32 + char buf[MAX_PATH] = {0}; + DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf)); + std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public"; + return base + "\\AppData\\Local\\navegator_profiles\\" + profile; +#else + (void)profile; + return ""; +#endif +} + +// ---------- Endpoint handlers ---------- +struct Response { + int status = 200; + std::string content_type = "application/json"; + std::string body; +}; + +Response handle_health() { + Response r; + r.body = "{\"ok\":true,\"app\":\"navegator_dashboard\"}"; + return r; +} + +Response handle_browsers() { + Response r; + auto list = scan_chrome_instances(); + std::ostringstream os; + os << "["; + for (size_t i = 0; i < list.size(); ++i) { + if (i) os << ","; + os << instance_to_json(list[i]); + } + os << "]"; + r.body = os.str(); + return r; +} + +Response handle_spawn(const std::map& q) { + Response r; + LaunchOpts o; + auto profile_it = q.find("profile"); + std::string profile = (profile_it != q.end() && !profile_it->second.empty()) + ? profile_it->second : "default"; + auto port_it = q.find("port"); + if (port_it != q.end() && !port_it->second.empty()) { + try { o.port = std::stoi(port_it->second); } catch (...) {} + } + auto headless_it = q.find("headless"); + if (headless_it != q.end()) { + const std::string& v = headless_it->second; + o.headless = (v == "1" || v == "true" || v == "yes"); + } + auto udd_it = q.find("user_data_dir"); + o.user_data_dir = (udd_it != q.end() && !udd_it->second.empty()) + ? udd_it->second : default_user_data_dir(profile); + auto url_it = q.find("url"); + if (url_it != q.end()) o.start_url = url_it->second; + + auto res = launch_chrome(o); + std::ostringstream os; + os << "{\"ok\":" << (res.ok ? "true" : "false") + << ",\"pid\":" << res.pid + << ",\"port\":" << o.port + << ",\"profile\":\"" << json_escape(profile) << "\"" + << ",\"user_data_dir\":\"" << json_escape(o.user_data_dir) << "\""; + if (!res.ok) { + os << ",\"error\":\"" << json_escape(res.error) << "\""; + r.status = 500; + } + os << "}"; + r.body = os.str(); + return r; +} + +Response handle_kill(const std::map& q) { + Response r; + std::string udd; + auto udd_it = q.find("user_data_dir"); + if (udd_it != q.end() && !udd_it->second.empty()) { + udd = udd_it->second; + } else { + auto p_it = q.find("profile"); + if (p_it != q.end() && !p_it->second.empty()) { + udd = default_user_data_dir(p_it->second); + } + } + if (udd.empty()) { + r.status = 400; + r.body = "{\"ok\":false,\"error\":\"need profile= or user_data_dir=\"}"; + return r; + } + int killed = kill_chromes_by_userdata(udd); + std::ostringstream os; + os << "{\"ok\":" << (killed >= 0 ? "true" : "false") + << ",\"killed\":" << (killed < 0 ? 0 : killed) + << ",\"user_data_dir\":\"" << json_escape(udd) << "\"}"; + r.body = os.str(); + return r; +} + +Response handle_not_found(const std::string& path) { + Response r; + r.status = 404; + r.body = std::string("{\"ok\":false,\"error\":\"not found: ") + json_escape(path) + "\"}"; + return r; +} + +Response dispatch(const std::string& method, const std::string& path, const std::string& query) { + auto q = parse_query(query); + if (method == "GET" && path == "/health") return handle_health(); + if (method == "GET" && path == "/browsers") return handle_browsers(); + if (method == "POST" && path == "/spawn") return handle_spawn(q); + if (method == "POST" && path == "/kill") return handle_kill(q); + return handle_not_found(path); +} + +// ---------- HTTP request parsing ---------- +struct Request { + std::string method; + std::string path; + std::string query; +}; + +bool parse_request_line(const std::string& line, Request& req) { + size_t sp1 = line.find(' '); + if (sp1 == std::string::npos) return false; + size_t sp2 = line.find(' ', sp1 + 1); + if (sp2 == std::string::npos) return false; + req.method = line.substr(0, sp1); + std::string url = line.substr(sp1 + 1, sp2 - sp1 - 1); + size_t qm = url.find('?'); + if (qm == std::string::npos) { + req.path = url; + req.query = ""; + } else { + req.path = url.substr(0, qm); + req.query = url.substr(qm + 1); + } + return true; +} + +#ifdef _WIN32 +void send_all(SOCKET s, const std::string& data) { + size_t sent = 0; + while (sent < data.size()) { + int n = ::send(s, data.data() + sent, (int)(data.size() - sent), 0); + if (n <= 0) return; + sent += (size_t)n; + } +} + +void handle_connection(SOCKET client) { + // Leer hasta encontrar \r\n\r\n (cabeceras). Body POST: ignoramos para v0 + // (los params van en query string, mas simple). + std::string buf; + char tmp[4096]; + for (int i = 0; i < 16; ++i) { + int n = ::recv(client, tmp, sizeof(tmp), 0); + if (n <= 0) break; + buf.append(tmp, n); + if (buf.find("\r\n\r\n") != std::string::npos) break; + if (buf.size() > 65536) break; + } + + Request req; + Response resp; + size_t end_line = buf.find("\r\n"); + if (end_line == std::string::npos || !parse_request_line(buf.substr(0, end_line), req)) { + resp.status = 400; + resp.body = "{\"ok\":false,\"error\":\"bad request\"}"; + } else { + resp = dispatch(req.method, req.path, req.query); + } + + std::ostringstream out; + const char* status_text = "OK"; + if (resp.status == 400) status_text = "Bad Request"; + else if (resp.status == 404) status_text = "Not Found"; + else if (resp.status == 500) status_text = "Internal Server Error"; + out << "HTTP/1.1 " << resp.status << " " << status_text << "\r\n" + << "Content-Type: " << resp.content_type << "\r\n" + << "Content-Length: " << resp.body.size() << "\r\n" + << "Access-Control-Allow-Origin: *\r\n" + << "Connection: close\r\n" + << "\r\n" + << resp.body; + send_all(client, out.str()); + closesocket(client); + g_api_request_count.fetch_add(1); +} + +void server_loop(int port) { + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return; + + SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) { WSACleanup(); return; } + BOOL yes = TRUE; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof(yes)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons((u_short)port); + addr.sin_addr.s_addr = htonl(0x7F000001); // 127.0.0.1 (loopback) + + if (::bind(listener, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { + closesocket(listener); WSACleanup(); + g_api_running.store(false); + return; + } + if (::listen(listener, 16) == SOCKET_ERROR) { + closesocket(listener); WSACleanup(); + g_api_running.store(false); + return; + } + + g_api_running.store(true); + g_api_port.store(port); + + while (true) { + sockaddr_in client_addr{}; + int alen = sizeof(client_addr); + SOCKET client = ::accept(listener, (sockaddr*)&client_addr, &alen); + if (client == INVALID_SOCKET) continue; + std::thread(handle_connection, client).detach(); + } +} +#endif + +} // namespace + +void start_api_server(int port) { +#ifdef _WIN32 + if (g_api_running.load()) return; + std::thread([port]{ server_loop(port); }).detach(); +#else + (void)port; +#endif +} + +} // namespace navegator diff --git a/local_api.h b/local_api.h new file mode 100644 index 0000000..d296fad --- /dev/null +++ b/local_api.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace navegator { + +// Inicia un servidor HTTP local en 127.0.0.1:port en un thread detached. +// Idempotente — si ya esta corriendo, no hace nada. +// +// Endpoints v1: +// GET /health -> "ok" +// GET /browsers -> JSON array +// POST /spawn?profile=X&port=N&headless=0|1 -> JSON {ok,pid,port} +// POST /kill?user_data_dir=... -> JSON {killed:N} +// POST /kill?profile=NAME -> JSON {killed:N} +// +// El servidor solo bindea a 127.0.0.1 (loopback), no expone fuera del PC. +// WSL2 con mirrored networking ya alcanza este bind desde WSL. +void start_api_server(int port = 19333); + +// Estado para mostrar en la UI (panel Info). +extern std::atomic g_api_running; +extern std::atomic g_api_port; +extern std::atomic g_api_request_count; + +} // namespace navegator diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..96bf29e --- /dev/null +++ b/main.cpp @@ -0,0 +1,59 @@ +// navegator_dashboard — cuadro de mandos para gestionar instancias Chrome con CDP. +// +// v0: Browsers panel funcional + 3 stubs (Tabs, Tab Detail, Network). +// Ver projects/navegator/apps/navegator_dashboard/app.md para arquitectura completa. + +#include "app_base.h" +#include "core/icons_tabler.h" +#include "core/panel_menu.h" +#include "imgui.h" + +#include "local_api.h" + +namespace navegator { +void render_browsers_panel(bool* p_open); +void render_tabs_panel(bool* p_open); +void render_tab_detail_panel(bool* p_open); +void render_network_panel(bool* p_open); +} + +namespace { +bool show_browsers = true; +bool show_tabs = true; +bool show_tab_detail = false; +bool show_network = false; + +constexpr fn_ui::PanelToggle k_panels[] = { + {"Browsers", "Ctrl+1", &show_browsers}, + {"Tabs", "Ctrl+2", &show_tabs}, + {"Tab Detail", "Ctrl+3", &show_tab_detail}, + {"Network", "Ctrl+4", &show_network}, +}; +} // namespace + +static void render_dashboard() { + ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport()); + if (show_browsers) navegator::render_browsers_panel(&show_browsers); + if (show_tabs) navegator::render_tabs_panel(&show_tabs); + if (show_tab_detail) navegator::render_tab_detail_panel(&show_tab_detail); + if (show_network) navegator::render_network_panel(&show_network); +} + +int main() { + fn::AppConfig cfg; + cfg.title = "Navegator Dashboard"; + cfg.about = { + "Navegator Dashboard", + "0.1.0", + "Cuadro de mandos para Chrome con remote debugging — v0 Browsers panel." + }; + cfg.panels = k_panels; + cfg.panel_count = sizeof(k_panels) / sizeof(k_panels[0]); + cfg.init_gl_loader = false; + + // HTTP API local (loopback). 127.0.0.1:19333. + // Endpoints: /health, /browsers, /spawn, /kill — ver local_api.h. + navegator::start_api_server(19333); + + return fn::run_app(cfg, render_dashboard); +} diff --git a/panels.cpp b/panels.cpp new file mode 100644 index 0000000..a15a952 --- /dev/null +++ b/panels.cpp @@ -0,0 +1,264 @@ +// Panels v0: +// - Browsers: scan + spawn + kill (funcional) +// - Tabs / Tab Detail / Network: stubs anunciando v1. + +#include "imgui.h" +#include "core/icons_tabler.h" +#include "core/tokens.h" + +#include "chrome_scanner.h" +#include "chrome_launcher.h" +#include "local_api.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#endif + +namespace navegator { + +// ---------- Estado compartido del panel Browsers ---------- +namespace { + +struct BrowsersState { + std::mutex mu; + std::vector instances; + std::chrono::steady_clock::time_point last_scan; + std::atomic scanning{false}; + std::atomic ever_scanned{false}; + std::atomic selected{-1}; + char new_profile[128] = "default"; + int new_port = 19222; + bool new_headless = false; + std::string last_error; +}; + +BrowsersState g_browsers; + +void rescan_async() { + if (g_browsers.scanning.exchange(true)) return; + std::thread([]{ + auto v = scan_chrome_instances(); + { + std::lock_guard lk(g_browsers.mu); + g_browsers.instances = std::move(v); + g_browsers.last_scan = std::chrono::steady_clock::now(); + } + g_browsers.ever_scanned.store(true); + g_browsers.scanning.store(false); + }).detach(); +} + +std::string default_user_data_dir(const std::string& profile) { + // Resolver USERPROFILE si esta disponible. +#ifdef _WIN32 + char buf[MAX_PATH] = {0}; + DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf)); + std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public"; + return base + "\\AppData\\Local\\navegator_profiles\\" + profile; +#else + return std::string("/tmp/navegator_profiles/") + profile; +#endif +} + +} // namespace + +// ---------- Browsers panel ---------- + +void render_browsers_panel(bool* p_open) { + if (!ImGui::Begin(TI_BROWSER " Browsers", p_open)) { + ImGui::End(); + return; + } + + // Auto-rescan cada 2s. + auto now = std::chrono::steady_clock::now(); + { + std::lock_guard lk(g_browsers.mu); + if (now - g_browsers.last_scan > std::chrono::seconds(2) && !g_browsers.scanning.load()) { + rescan_async(); + } + } + + // API status badge. + if (g_api_running.load()) { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success); + ImGui::Text("API: 127.0.0.1:%d", g_api_port.load()); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("(reqs: %d)", g_api_request_count.load()); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error); + ImGui::TextUnformatted("API: down"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + } + + // Toolbar. + if (ImGui::Button(TI_REFRESH " Rescan")) rescan_async(); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + ImGui::TextUnformatted("New profile:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(160); + ImGui::InputText("##profile", g_browsers.new_profile, sizeof(g_browsers.new_profile)); + ImGui::SameLine(); + ImGui::TextUnformatted("Port:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + ImGui::InputInt("##port", &g_browsers.new_port, 0, 0); + ImGui::SameLine(); + ImGui::Checkbox("Headless", &g_browsers.new_headless); + ImGui::SameLine(); + if (ImGui::Button(TI_PLAYER_PLAY " Launch")) { + LaunchOpts o; + o.port = g_browsers.new_port; + o.headless = g_browsers.new_headless; + std::string profile = g_browsers.new_profile; + if (profile.empty()) profile = "default"; + o.user_data_dir = default_user_data_dir(profile); + auto r = launch_chrome(o); + g_browsers.last_error = r.ok ? "" : r.error; + // Forzar rescan inmediato (con pequeño delay para que Chrome aparezca en CIM). + std::thread([]{ + std::this_thread::sleep_for(std::chrono::milliseconds(800)); + rescan_async(); + }).detach(); + } + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + if (ImGui::Button(TI_X " Kill all navegator")) { + kill_chromes_by_userdata("navegator_profiles"); + std::thread([]{ + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + rescan_async(); + }).detach(); + } + + if (!g_browsers.last_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error); + ImGui::TextWrapped("Error: %s", g_browsers.last_error.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + // Tabla. + std::lock_guard lk(g_browsers.mu); + // Anti-flicker: solo mostrar "Scanning..." en el primer scan (cuando aun + // no tenemos datos). Una vez tenemos al menos un resultado, mantener el + // empty-state estable; el badge "(reqs:N)" + el rescan async siguen + // corriendo en background sin tocar la UI. + if (!g_browsers.ever_scanned.load() && g_browsers.instances.empty()) { + ImGui::TextUnformatted("Scanning..."); + } else if (g_browsers.instances.empty()) { + ImGui::TextDisabled("No Chrome instances with --remote-debugging-port detected."); + ImGui::TextDisabled("Lanza una con el formulario de arriba o con scripts/start.sh."); + } else { + const ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_Sortable; + if (ImGui::BeginTable("##browsers", 6, flags)) { + ImGui::TableSetupColumn("PID", ImGuiTableColumnFlags_WidthFixed, 64); + ImGui::TableSetupColumn("Port", ImGuiTableColumnFlags_WidthFixed, 64); + ImGui::TableSetupColumn("Profile"); + ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 80); + ImGui::TableSetupColumn("user-data-dir"); + ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 110); + ImGui::TableHeadersRow(); + + int idx = 0; + for (const auto& inst : g_browsers.instances) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("%u", inst.pid); + ImGui::TableNextColumn(); + ImGui::Text("%d", inst.port); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(inst.profile_name.empty() ? "(none)" : inst.profile_name.c_str()); + ImGui::TableNextColumn(); + if (inst.headless) { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::warning); + ImGui::TextUnformatted("headless"); + ImGui::PopStyleColor(); + } else { + ImGui::TextUnformatted("visible"); + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(inst.user_data_dir.c_str()); + ImGui::TableNextColumn(); + ImGui::PushID(idx); + if (ImGui::SmallButton("Kill")) { + if (!inst.user_data_dir.empty()) { + kill_chromes_by_userdata(inst.user_data_dir); + } + std::thread([]{ + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + rescan_async(); + }).detach(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Inspect")) { + g_browsers.selected = idx; + } + ImGui::PopID(); + ++idx; + } + ImGui::EndTable(); + } + } + ImGui::End(); +} + +// ---------- Tabs panel (stub) ---------- +void render_tabs_panel(bool* p_open) { + if (!ImGui::Begin(TI_LIST " Tabs", p_open)) { + ImGui::End(); + return; + } + ImGui::TextDisabled("Coming in v1"); + ImGui::TextWrapped("Listara las pestañas de la instancia seleccionada en Browsers via " + "CDP /json y permitira navigate/close/focus."); + ImGui::End(); +} + +// ---------- Tab Detail panel (stub) ---------- +void render_tab_detail_panel(bool* p_open) { + if (!ImGui::Begin(TI_FILE_INFO " Tab Detail", p_open)) { + ImGui::End(); + return; + } + ImGui::TextDisabled("Coming in v1"); + ImGui::TextWrapped("HTML preview, screenshot live y REPL Runtime.evaluate sobre la pestaña " + "seleccionada."); + ImGui::End(); +} + +// ---------- Network panel (stub) ---------- +void render_network_panel(bool* p_open) { + if (!ImGui::Begin(TI_ACTIVITY " Network", p_open)) { + ImGui::End(); + return; + } + ImGui::TextDisabled("Coming in v1"); + ImGui::TextWrapped("Log de peticiones HTTP/WS en vivo via CDP Network.* events. " + "Headers, body, timing, filtros."); + ImGui::End(); +} + +} // namespace navegator diff --git a/tests/e2e_api.sh b/tests/e2e_api.sh new file mode 100755 index 0000000..d6561ab --- /dev/null +++ b/tests/e2e_api.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# e2e_api.sh — Suite end-to-end de la API HTTP de navegator_dashboard. +# +# Asume que `navegator_dashboard.exe` esta corriendo con la API en 127.0.0.1:19333. +# Si no esta, lanza la app primero (no la mata al final — dejarla viva para iterar). +# +# Cubre: +# 1. /health responde OK +# 2. /browsers retorna JSON array +# 3. POST /spawn lanza chrome.exe + retorna {ok:true,pid,port} +# 4. CDP responde en el puerto retornado +# 5. cdp-cli puede navegar/evaluar sobre la instancia +# 6. /browsers ahora contiene la instancia (1 entrada por chrome master, no N) +# 7. POST /kill mata la instancia +# 8. CDP deja de responder +# 9. /browsers vuelve a no contenerla +# +# Exit 0 en exito, !=0 en fallo. Cada paso imprime "OK" o "FAIL: ..." a stderr. + +set -euo pipefail + +API="${NAVD_API:-http://127.0.0.1:19333}" +EXE="${NAVD_EXE:-/mnt/c/Users/lucas/Desktop/apps/navegator_dashboard/navegator_dashboard.exe}" +CDP_CLI="${NAVD_CDP_CLI:-/home/lucas/fn_registry/projects/osint_graph/apps/graph_explorer/cdp-cli/cdp-cli}" + +PROFILE="e2e_$(date +%s)" +PORT=19299 + +failures=0 +step() { echo; echo "==[ $1 ]==" >&2; } +ok() { echo " OK: $1" >&2; } +fail() { echo " FAIL: $1" >&2; failures=$((failures + 1)); } + +# --------------------------------------------------------- +step "0. dashboard reachable" +# --------------------------------------------------------- +if ! curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then + if [[ -x "$EXE" ]]; then + echo " app no responde, lanzando $EXE..." >&2 + powershell.exe -NoProfile -Command "Start-Process -FilePath '$(wslpath -w "$EXE" 2>/dev/null || echo "$EXE")'" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do + if curl -sf --max-time 1 "$API/health" >/dev/null 2>&1; then break; fi + sleep 0.5 + done + fi +fi +if curl -sf --max-time 2 "$API/health" >/dev/null 2>&1; then + ok "dashboard up" +else + fail "dashboard no responde en $API/health" + exit 1 +fi + +# --------------------------------------------------------- +step "1. /health" +# --------------------------------------------------------- +body="$(curl -sf --max-time 2 "$API/health")" +echo " body: $body" >&2 +[[ "$body" == *'"ok":true'* ]] && ok "/health responde ok=true" || fail "/health body inesperado" + +# --------------------------------------------------------- +step "2. /browsers (initial)" +# --------------------------------------------------------- +body="$(curl -sf --max-time 2 "$API/browsers")" +echo " body: $body" >&2 +[[ "$body" == \[* ]] && ok "/browsers retorna array" || fail "/browsers no retorna JSON array" + +# --------------------------------------------------------- +step "3. POST /spawn" +# --------------------------------------------------------- +body="$(curl -sf --max-time 10 -X POST "$API/spawn?profile=$PROFILE&port=$PORT")" +echo " body: $body" >&2 +if [[ "$body" == *'"ok":true'* ]]; then + ok "/spawn launched" + SPAWNED_PID="$(echo "$body" | sed -nE 's/.*"pid":([0-9]+).*/\1/p')" + echo " pid=$SPAWNED_PID port=$PORT profile=$PROFILE" >&2 +else + fail "/spawn fallo" + exit 1 +fi + +# --------------------------------------------------------- +step "4. CDP responde en puerto $PORT" +# --------------------------------------------------------- +ok_cdp="" +for _ in $(seq 1 20); do + if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then + ok_cdp=1; break + fi + sleep 0.5 +done +if [[ -n "$ok_cdp" ]]; then + browser="$(curl -sf "http://127.0.0.1:$PORT/json/version" 2>/dev/null | grep -i '"Browser"' | head -1 | tr -d '\r' || true)" + ok "CDP up —${browser}" +else + fail "CDP no responde en $PORT" +fi + +# --------------------------------------------------------- +step "5. cdp-cli navigate + evaluate" +# --------------------------------------------------------- +if [[ -x "$CDP_CLI" ]]; then + "$CDP_CLI" navigate --port "$PORT" --url "data:text/html,

e2e ok

" >/dev/null 2>&1 \ + && ok "navigate OK" || fail "navigate fallo" + title="$("$CDP_CLI" evaluate --port "$PORT" --js "document.getElementById('t').innerText" 2>/dev/null || echo "")" + [[ "$title" == "e2e ok" ]] && ok "evaluate retorno texto correcto" || fail "evaluate retorno: '$title'" +else + fail "cdp-cli no encontrado en $CDP_CLI" +fi + +# --------------------------------------------------------- +step "6. /browsers contiene instancia (filtro --type=)" +# --------------------------------------------------------- +sleep 2 # dar tiempo a que se note en el rescan async del dashboard +body="$(curl -sf --max-time 5 "$API/browsers")" +echo " body: $body" >&2 +count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)" +echo " matches profile $PROFILE: $count_match" >&2 +if [[ "$count_match" == "1" ]]; then + ok "exactamente 1 entrada (master only, no renderers)" +elif [[ "$count_match" -ge "1" ]]; then + fail "esperaba 1, encontrado $count_match (filtro --type= roto?)" +else + fail "no encuentro el perfil $PROFILE en /browsers" +fi + +# --------------------------------------------------------- +step "7. POST /kill" +# --------------------------------------------------------- +body="$(curl -sf --max-time 5 -X POST "$API/kill?profile=$PROFILE")" +echo " body: $body" >&2 +[[ "$body" == *'"ok":true'* ]] && ok "/kill respondio ok" || fail "/kill fallo" + +# --------------------------------------------------------- +step "8. CDP ya no responde" +# --------------------------------------------------------- +sleep 2 +if curl -sf --max-time 1 "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then + fail "CDP sigue vivo en $PORT despues de kill" +else + ok "CDP muerto" +fi + +# --------------------------------------------------------- +step "9. /browsers ya no contiene instancia" +# --------------------------------------------------------- +sleep 1 +body="$(curl -sf --max-time 5 "$API/browsers")" +count_match="$(echo "$body" | grep -c "\"profile\":\"$PROFILE\"" || true)" +[[ "$count_match" == "0" ]] && ok "instancia desaparecida" || fail "/browsers sigue listando perfil $PROFILE ($count_match veces)" + +# --------------------------------------------------------- +echo +if [[ $failures -eq 0 ]]; then + echo "===== e2e_api.sh: ALL PASS =====" >&2 + exit 0 +else + echo "===== e2e_api.sh: $failures FAILURES =====" >&2 + exit 1 +fi