package webapp // CLI onboarding e2e: builds the real bdrive binary and drives the exact // commands an agent (or the INSTALL_FOR_AGENTS.md runbook) runs against an // in-process hub — device-code login, init, re-init, hooks, stop. This is // the deterministic half of onboarding testing; the conversational half // (does the agent ask before mounting?) lives in the onboarding-e2e skill. // // The key regression this guards: `bdrive init` must register agent sync // hooks itself — a separate `bdrive hooks install` gets blocked by agent // permission classifiers, which is how teams end up without hooks. import ( "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "testing" "time" "github.com/runbear-io/beardrive/internal/remote" ) // cliEnv is a signed-in CLI against a throwaway hub: the real binary, an // isolated HOME/BDRIVE_HOME, and a browser session for hub-side assertions. type cliEnv struct { run func(dir string, args ...string) (string, error) hub *httptest.Server browser *http.Client home string // the isolated HOME; hooks live under here now bin string // the real binary, for tests that need a bdrive to run } func newCLIEnv(t *testing.T) cliEnv { t.Helper() return newCLIEnvOn(t, nil) } // newCLIEnvOn is newCLIEnv against an existing hub, so a test can put two // DEVICES (separate BDRIVE_HOMEs, separate device identities) on one project. // nil starts a throwaway hub, which is the single-device default. func newCLIEnvOn(t *testing.T, hub *httptest.Server) cliEnv { t.Helper() return newCLIEnvBin(t, hub, "") } // newCLIEnvBin is newCLIEnvOn with an explicit binary — the delta-sync E2E // rows drive a binary built from the pre-change commit against the same hub // as a current one. Empty means build the current tree. func newCLIEnvBin(t *testing.T, hub *httptest.Server, bin string) cliEnv { t.Helper() if testing.Short() { t.Skip("builds and execs the bdrive binary; skipped with -short") } if bin == "" { bin = filepath.Join(t.TempDir(), "bdrive") build := exec.Command("go", "build", "-o", bin, "github.com/runbear-io/beardrive/cmd/bdrive") if out, err := build.CombinedOutput(); err != nil { t.Fatalf("go build: %v\n%s", err, out) } } if hub == nil { hub = startTestHub(t) } // Isolate the CLI completely: fresh BDRIVE_HOME and a fresh HOME, so // agent-platform detection can't see or touch the real ~/.codex etc. home := t.TempDir() env := append(envWithout("HOME", "BDRIVE_HOME"), "HOME="+home, "BDRIVE_HOME="+filepath.Join(home, ".bdrive")) run := func(dir string, args ...string) (string, error) { cmd := exec.Command(bin, args...) cmd.Dir = dir cmd.Env = env out, err := cmd.CombinedOutput() return string(out), err } // Sign in via the real device-code flow, approved over HTTP as the // runbook's "any signed-in browser" (a cookie session from /auth/login). login := exec.Command(bin, "login", "--device", hub.URL) login.Env = env logFile := filepath.Join(t.TempDir(), "login.log") f, err := os.Create(logFile) if err != nil { t.Fatal(err) } login.Stdout, login.Stderr = f, f if err := login.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { login.Process.Kill() }) approve := waitForApprovalLink(t, logFile) browser := signedInBrowser(t, hub.URL) if _, err := browser.PostForm(approve, nil); err != nil { t.Fatal(err) } if err := login.Wait(); err != nil { out, _ := os.ReadFile(logFile) t.Fatalf("login --device: %v\n%s", err, out) } return cliEnv{run: run, hub: hub, browser: browser, home: home, bin: bin} } func TestCLIOnboardingE2E(t *testing.T) { e := newCLIEnv(t) run, hub, browser := e.run, e.hub, e.browser // --- Init in a folder where Claude Code is in use. Hooks must be // registered by init itself, before the first sync output. work := filepath.Join(t.TempDir(), "proj") if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o755); err != nil { t.Fatal(err) } defer run(work, "stop", work) // don't leak the daemon on failure out, err := run(work, "init", "--name", "cli-e2e", "--yes") if err != nil { t.Fatalf("init: %v\n%s", err, out) } if !strings.Contains(out, "claude") || !strings.Contains(out, "hooks registered") { t.Fatalf("init did not report registering claude hooks:\n%s", out) } settings, err := os.ReadFile(filepath.Join(e.home, ".claude", "settings.json")) if err != nil { t.Fatalf("init did not write the user's .claude/settings.json: %v", err) } for _, want := range []string{"bdrive sync", "bdrive read-log"} { if !strings.Contains(string(settings), want) { t.Fatalf("user settings.json missing %q hook:\n%s", want, settings) } } // Nothing agent-shaped may be created inside the project: it would sync. assertNoProjectHookFiles(t, work) // init's star ask is for humans at a terminal only. `run` pipes stdout, // which is exactly the shape of a CI job or a script parsing the output — // asking there is the mistake that got postinstall ads banned from npm. // (Matched on the repo URL, not the word "star" — `autostart registered` // is a legitimate line that contains it.) if strings.Contains(out, "github.com/runbear-io/beardrive") { t.Fatalf("init asked for a GitHub star without a TTY:\n%s", out) } // A reboot kills the daemon, so init registers the login agent that // brings it back. It must land in the user's own LaunchAgents dir (this // test's isolated HOME) and point at `bdrive resume`, which covers every // mount rather than needing one registration per project. if runtime.GOOS == "darwin" { plist := filepath.Join(e.home, "Library", "LaunchAgents", "ai.beardrive.daemon.plist") body, err := os.ReadFile(plist) if err != nil { t.Fatalf("init did not register the login agent: %v", err) } if !strings.Contains(string(body), "resume") { t.Fatalf("login agent does not run `bdrive resume`:\n%s", body) } if out, err := run(work, "autostart"); err != nil || !strings.Contains(out, "registered") { t.Fatalf("autostart status: %v\n%s", err, out) } } // resume is idempotent against a live daemon — the login agent runs it on // a machine where nothing is stopped, and must not start a second one. out, err = run(work, "resume") if err != nil || !strings.Contains(out, "already running 1") { t.Fatalf("resume should have found the running daemon: %v\n%s", err, out) } // The hooks are the whole agent integration: init must not install a // skill file anywhere, and no `skill` subcommand may come back. for _, agent := range []string{"claude", "codex", "gemini", "hermes"} { p := filepath.Join(e.home, "."+agent, "skills", "beardrive", "SKILL.md") if _, err := os.Stat(p); err == nil { t.Fatalf("init installed a skill at %s — the hooks are the integration now", p) } } if out, err := run(work, "skill"); err == nil { t.Fatalf("`bdrive skill` still exists:\n%s", out) } // The project must actually exist on the hub, created under the account. if projects := hubProjects(t, browser, hub.URL); !strings.Contains(projects, "cli-e2e") { t.Fatalf("hub project list missing cli-e2e: %s", projects) } // --- Re-running init resumes and converges hooks idempotently. out, err = run(work, "init", "--yes") if err != nil { t.Fatalf("re-init: %v\n%s", err, out) } if !strings.Contains(out, "resuming") || !strings.Contains(out, "hooks already registered") { t.Fatalf("re-init should resume with hooks already registered:\n%s", out) } if out, err = run(work, "hooks"); err != nil || !strings.Contains(out, "hooks registered") { t.Fatalf("hooks status: %v\n%s", err, out) } if out, err = run(work, "stop", work); err != nil { t.Fatalf("stop: %v\n%s", err, out) } cfg1, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json")) if err != nil { t.Fatal(err) } // --- A second mount on the same machine (the "add another folder" flow) // must create a separate project and leave the first mount untouched. // Also the opt-out: --no-hooks must leave the platform config alone. work2 := filepath.Join(t.TempDir(), "proj2") if err := os.MkdirAll(filepath.Join(work2, ".claude"), 0o755); err != nil { t.Fatal(err) } defer run(work2, "stop", work2) out, err = run(work2, "init", "--name", "cli-e2e-nohooks", "--yes", "--no-hooks", "--no-autostart") if err != nil { t.Fatalf("init --no-hooks: %v\n%s", err, out) } if _, err := os.Stat(filepath.Join(work2, ".claude", "settings.json")); !os.IsNotExist(err) { t.Fatalf("--no-hooks still wrote .claude/settings.json (stat err: %v)", err) } if strings.Contains(out, "login:") { t.Fatalf("--no-autostart still touched the login agent:\n%s", out) } if out, err = run(work2, "stop", work2); err != nil { t.Fatalf("stop: %v\n%s", err, out) } cfg1b, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json")) if err != nil || string(cfg1) != string(cfg1b) { t.Fatalf("second init disturbed the first mount's config (err %v):\nbefore: %s\nafter: %s", err, cfg1, cfg1b) } projects := hubProjects(t, browser, hub.URL) for _, want := range []string{"cli-e2e", "cli-e2e-nohooks"} { if !strings.Contains(projects, want) { t.Fatalf("hub missing project %q after second init: %s", want, projects) } } } // Two sibling folders under one parent, each synced to a DIFFERENT project — // the shape a second project lands in on a machine that already syncs one. // The mounts must stay fully independent: separate ids, separate content, and // the first one's config untouched by the second's init. func TestCLISiblingProjectMounts(t *testing.T) { e := newCLIEnv(t) run, hub, browser := e.run, e.hub, e.browser parent := t.TempDir() a, b := filepath.Join(parent, "a"), filepath.Join(parent, "b") for dir, file := range map[string]string{a: "brand.md", b: "adr.md"} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, file), []byte("# "+file+"\n"), 0o644); err != nil { t.Fatal(err) } } if out, err := run(a, "init", "--name", "project-a", "--yes"); err != nil { t.Fatalf("init a: %v\n%s", err, out) } defer run(a, "stop", a) cfgA, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json")) if err != nil { t.Fatal(err) } if out, err := run(b, "init", "--name", "project-b", "--yes"); err != nil { t.Fatalf("init b: %v\n%s", err, out) } defer run(b, "stop", b) // The second init must not have touched the first mount. cfgAafter, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json")) if err != nil || string(cfgA) != string(cfgAafter) { t.Fatalf("init in b/ changed a/'s config (err %v):\nbefore: %s\nafter: %s", err, cfgA, cfgAafter) } cfgB, err := os.ReadFile(filepath.Join(b, ".bdrive", "config.json")) if err != nil { t.Fatal(err) } if string(cfgA) == string(cfgB) { t.Fatalf("sibling mounts share a config: %s", cfgA) } // Each project holds its own file and not the other's. idA, idB := projectIDByName(t, browser, hub.URL, "project-a"), projectIDByName(t, browser, hub.URL, "project-b") pathsA, pathsB := hubPaths(t, browser, hub.URL, idA), hubPaths(t, browser, hub.URL, idB) if !pathsA["brand.md"] || pathsA["adr.md"] { t.Fatalf("project-a content wrong: %v", pathsA) } if !pathsB["adr.md"] || pathsB["brand.md"] { t.Fatalf("project-b content wrong: %v", pathsB) } } // Connecting a second device to an existing project, over a real hub, with the // files that actually collide in practice: the `.bdriveignore` init seeds and an // AGENTS.md an agent wrote in the folder before it was ever synced. Neither may // fork into a `.bdriveignore.bdrive-conflict--