homelab v0.8.0: browser verbs for headful anti-bot web automation
Add `homelab browser run|open` so agents can drive the cluster's headful
Chrome (chrome-service) over CDP from the devvm. The headless playwright/mcp
browser can load anti-bot sites and fill their forms, but the gated submit
silently fails — e.g. the Stirling Ackroyd Fixflo tenant portal returned
net::ERR_FILE_NOT_FOUND on its pre-submit check and hung, creating nothing.
Driving the real headful Chrome submits first try. That capability already
existed but was undiscoverable, so it cost ~40 min + redundant form re-runs to
find; now it is one command, versioned, test-covered, and `browser --help`
carries the when-to-use signature + an error-code cheat-sheet so the right tool
is reached at the right moment (the failure was judgment, not setup).
- port-forward svc/chrome-service:9222 (tunnels API-server->pod, so it bypasses
the :9222 NetworkPolicy), assert non-headless via /json/version,
connect_over_cdp, inject the same vendored stealth.js the in-cluster callers
use; the port-forward is always torn down, on success and on error.
- node CDP client pinned to playwright-core@1.48.2 to match the v1.48.0-noble
image (Chromium 130); self-provisioned lazily into ~/.cache/homelab, no
per-user setup.
- default is a fresh incognito context (safe for the shared browser + concurrent
callers); --shared-context reuses the warmed persistent profile.
- TDD: cmd_browser_test.go covers arg parsing, headless detection, the version
pin, the help cheat-sheet, and a stealth.js drift guard. Verified end-to-end
against bot.sannysoft.com (real Chrome UA, webdriver hidden, plugins/WebGL
spoofed) and `browser open`.
- docs: README v0.8 section, ADR-0013, and a chrome-service.md "driving from
outside the cluster" section.
Closes: code-nepg
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:22:22 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"reflect"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsRun(t *testing.T) {
|
|
|
|
|
got, err := parseBrowserArgs("run", []string{
|
|
|
|
|
"flow.js", "--url", "https://example.com", "--shared-context",
|
|
|
|
|
"--port", "19999", "--timeout", "45", "--keep-open",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("parseBrowserArgs run: unexpected err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
want := browserOpts{
|
|
|
|
|
mode: "run", script: "flow.js", url: "https://example.com",
|
|
|
|
|
sharedCtx: true, keepOpen: true, port: 19999, timeout: 45,
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
|
|
|
t.Fatalf("parseBrowserArgs run =\n %+v\nwant\n %+v", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsRunDefaults(t *testing.T) {
|
|
|
|
|
got, err := parseBrowserArgs("run", []string{"flow.js"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.script != "flow.js" || got.sharedCtx || got.keepOpen || got.port != 0 {
|
|
|
|
|
t.Fatalf("defaults wrong: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if got.timeout != defaultBrowserTimeout {
|
|
|
|
|
t.Fatalf("timeout default = %d, want %d", got.timeout, defaultBrowserTimeout)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsRunRequiresScript(t *testing.T) {
|
|
|
|
|
if _, err := parseBrowserArgs("run", []string{"--url", "https://x"}); err == nil {
|
|
|
|
|
t.Fatalf("run without a script path should error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsOpenRequiresURL(t *testing.T) {
|
|
|
|
|
got, err := parseBrowserArgs("open", []string{"https://example.com"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.url != "https://example.com" || got.mode != "open" {
|
|
|
|
|
t.Fatalf("open parse wrong: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
if _, err := parseBrowserArgs("open", []string{}); err == nil {
|
|
|
|
|
t.Fatalf("open without a URL should error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsHelp(t *testing.T) {
|
|
|
|
|
for _, a := range [][]string{{"--help"}, {"-h"}, {"flow.js", "--help"}} {
|
|
|
|
|
got, err := parseBrowserArgs("run", a)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("help parse %v: %v", a, err)
|
|
|
|
|
}
|
|
|
|
|
if !got.help {
|
|
|
|
|
t.Fatalf("args %v should set help", a)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseBrowserArgsEqualsForm(t *testing.T) {
|
|
|
|
|
got, err := parseBrowserArgs("run", []string{"flow.js", "--url=https://x", "--port=8123", "--timeout=10"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.url != "https://x" || got.port != 8123 || got.timeout != 10 {
|
|
|
|
|
t.Fatalf("--flag=value form not parsed: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCDPHealthy(t *testing.T) {
|
|
|
|
|
real := []byte(`{"Browser":"Chrome/130.0.6723.31","User-Agent":"Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0.0.0 Safari/537.36","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/x"}`)
|
|
|
|
|
browser, ok, err := cdpHealthy(real)
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("real Chrome should be healthy: ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(browser, "Chrome/") {
|
|
|
|
|
t.Fatalf("browser = %q, want Chrome/ prefix", browser)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
headless := []byte(`{"Browser":"HeadlessChrome/130.0.6723.31","User-Agent":"Mozilla/5.0 HeadlessChrome/130.0.0.0"}`)
|
|
|
|
|
if _, ok, _ := cdpHealthy(headless); ok {
|
|
|
|
|
t.Fatalf("HeadlessChrome must be reported unhealthy (the whole point of chrome-service)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, _, err := cdpHealthy([]byte("not json")); err == nil {
|
|
|
|
|
t.Fatalf("malformed /json/version body should error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildPortForwardArgs(t *testing.T) {
|
|
|
|
|
got := buildPortForwardArgs(18080)
|
|
|
|
|
want := []string{"-n", "chrome-service", "port-forward", "svc/chrome-service", "18080:9222"}
|
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
|
|
|
t.Fatalf("buildPortForwardArgs =\n %v\nwant\n %v", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBrowserClientPackageJSONPinsVersion(t *testing.T) {
|
|
|
|
|
pj := browserClientPackageJSON()
|
|
|
|
|
if !strings.Contains(pj, `"playwright-core": "`+playwrightVersion+`"`) {
|
|
|
|
|
t.Fatalf("package.json must pin playwright-core to %s; got:\n%s", playwrightVersion, pj)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaywrightVersionPinnedToServerMinor(t *testing.T) {
|
|
|
|
|
// chrome-service runs mcr.microsoft.com/playwright:v1.48.0-noble; the CDP
|
|
|
|
|
// client minor MUST match (protocol changes between minors).
|
|
|
|
|
if !strings.HasPrefix(playwrightVersion, "1.48.") {
|
|
|
|
|
t.Fatalf("playwrightVersion = %q, must be 1.48.x to match the chrome-service image", playwrightVersion)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBrowserHelpHasDiagnosticCheatSheet(t *testing.T) {
|
|
|
|
|
h := browserHelp()
|
|
|
|
|
for _, want := range []string{
|
|
|
|
|
"homelab browser run",
|
|
|
|
|
"ERR_FILE_NOT_FOUND",
|
|
|
|
|
"ERR_CONNECTION_REFUSED",
|
|
|
|
|
"network panel",
|
|
|
|
|
"headless",
|
|
|
|
|
"--shared-context",
|
|
|
|
|
} {
|
|
|
|
|
if !strings.Contains(h, want) {
|
|
|
|
|
t.Errorf("browser --help is missing %q (the discoverability/self-correction payload)", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 15:44:43 +00:00
|
|
|
func TestBrowserHelpIsTiered(t *testing.T) {
|
|
|
|
|
// --help must frame this as the ESCALATION path (default to headless first),
|
|
|
|
|
// matching ~/code/CLAUDE.md and chrome-service.md — non-conflicting agent
|
|
|
|
|
// instructions. Guard against a regression to "co-equal choice" wording.
|
|
|
|
|
h := browserHelp()
|
|
|
|
|
for _, want := range []string{"Default to the", "escalation"} {
|
|
|
|
|
if !strings.Contains(h, want) {
|
|
|
|
|
t.Errorf("browser --help must carry the tiered/default-headless framing; missing %q", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
homelab v0.8.0: browser verbs for headful anti-bot web automation
Add `homelab browser run|open` so agents can drive the cluster's headful
Chrome (chrome-service) over CDP from the devvm. The headless playwright/mcp
browser can load anti-bot sites and fill their forms, but the gated submit
silently fails — e.g. the Stirling Ackroyd Fixflo tenant portal returned
net::ERR_FILE_NOT_FOUND on its pre-submit check and hung, creating nothing.
Driving the real headful Chrome submits first try. That capability already
existed but was undiscoverable, so it cost ~40 min + redundant form re-runs to
find; now it is one command, versioned, test-covered, and `browser --help`
carries the when-to-use signature + an error-code cheat-sheet so the right tool
is reached at the right moment (the failure was judgment, not setup).
- port-forward svc/chrome-service:9222 (tunnels API-server->pod, so it bypasses
the :9222 NetworkPolicy), assert non-headless via /json/version,
connect_over_cdp, inject the same vendored stealth.js the in-cluster callers
use; the port-forward is always torn down, on success and on error.
- node CDP client pinned to playwright-core@1.48.2 to match the v1.48.0-noble
image (Chromium 130); self-provisioned lazily into ~/.cache/homelab, no
per-user setup.
- default is a fresh incognito context (safe for the shared browser + concurrent
callers); --shared-context reuses the warmed persistent profile.
- TDD: cmd_browser_test.go covers arg parsing, headless detection, the version
pin, the help cheat-sheet, and a stealth.js drift guard. Verified end-to-end
against bot.sannysoft.com (real Chrome UA, webdriver hidden, plugins/WebGL
spoofed) and `browser open`.
- docs: README v0.8 section, ADR-0013, and a chrome-service.md "driving from
outside the cluster" section.
Closes: code-nepg
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:22:22 +00:00
|
|
|
func TestStealthJSEmbeddedMatchesCanonical(t *testing.T) {
|
|
|
|
|
// The embedded copy must never drift from the source of truth that the
|
|
|
|
|
// in-cluster callers use, else the CLI's stealth and the cluster's diverge.
|
|
|
|
|
canonical, err := os.ReadFile("../stacks/chrome-service/files/stealth.js")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("read canonical stealth.js: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if stealthJS != string(canonical) {
|
|
|
|
|
t.Fatalf("cli/browser_stealth.js has drifted from stacks/chrome-service/files/stealth.js — re-copy it")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFreePortReturnsUsablePort(t *testing.T) {
|
|
|
|
|
p, err := freePort()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("freePort: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if p <= 1024 || p > 65535 {
|
|
|
|
|
t.Fatalf("freePort returned %d, want an ephemeral port", p)
|
|
|
|
|
}
|
|
|
|
|
}
|