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--