Files

406 lines
14 KiB
Go
Raw Permalink Normal View History

package syncer
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/templates"
)
// End-to-end scenarios for the init knowledge flows documented in
// plugin/commands/init.md and the beardrive skill ("Connecting knowledge
// tooling"): a shared subfolder carved out of a repo, a teammate connecting
// over pre-existing local content, and a nested mount (e.g. a team knowledge
// folder inside a personal brain that is itself a mount) syncing through its
// own project.
// deviceAt is newDevice with an explicit working folder, for topologies where
// the folder's location matters (nested mounts).
func deviceAt(t *testing.T, name, folder string, backend remote.Backend) *Session {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
return &Session{
Folder: folder,
Store: st,
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
Backend: backend,
}
}
func conflictFiles(t *testing.T, folder string) []string {
t.Helper()
var out []string
err := filepath.WalkDir(folder, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.Contains(d.Name(), ".bdrive-conflict-") {
out = append(out, p)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// A repo shares only its knowledge subfolder (a legacy include-list mount);
// a teammate connects from their own checkout with the same scope. Wiki
// content flows both ways; each side's code never leaves its machine.
func TestSharedSubfolderScopeBothDevices(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
write(t, a.Folder, "Wiki/home.md", "welcome")
write(t, a.Folder, "src/code.go", "package main")
cycle(t, a)
write(t, b.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
write(t, b.Folder, "main.py", "print('local only')")
res := cycle(t, b)
if res.LocalOps != 0 {
t.Fatalf("b journaled %d ops for out-of-scope files, want 0", res.LocalOps)
}
if got := read(t, b.Folder, "Wiki/home.md"); got != "welcome" {
t.Fatalf("Wiki/home.md = %q, want welcome", got)
}
// Wiki edits flow back; code never crosses in either direction.
write(t, b.Folder, "Wiki/home.md", "welcome v2")
cycle(t, b)
cycle(t, a)
if got := read(t, a.Folder, "Wiki/home.md"); got != "welcome v2" {
t.Fatalf("a Wiki/home.md = %q, want welcome v2", got)
}
for folder, absent := range map[string]string{a.Folder: "main.py", b.Folder: "src/code.go"} {
if _, err := os.Stat(filepath.Join(folder, absent)); !os.IsNotExist(err) {
t.Fatalf("%s leaked across devices", absent)
}
}
}
// The git-handoff teammate story: after the first user connects docs/, a
// teammate's checkout already holds the same files. Connecting must converge
// with zero conflict copies (identical content is adopted, not duplicated).
func TestConnectWithIdenticalLocalContent(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "docs/guide.md", "v1")
write(t, a.Folder, "docs/setup.md", "steps")
cycle(t, a)
b := newDevice(t, "devb", be)
write(t, b.Folder, "docs/guide.md", "v1")
write(t, b.Folder, "docs/setup.md", "steps")
cycle(t, b)
cycle(t, a)
cycle(t, b)
for _, s := range []*Session{a, b} {
if got := read(t, s.Folder, "docs/guide.md"); got != "v1" {
t.Fatalf("guide.md = %q, want v1", got)
}
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("identical content produced conflict copies: %v", c)
}
}
}
// Same story with a stale divergent copy. Joining is an adoption, not a merge:
// the project's version wins on every device — even though the joiner's copy is
// the later write — and no conflict copy is made. The joiner's content is not
// lost, it is journaled (and pushed) as a superseded version, which is what
// `bdrive restore` reads.
func TestConnectWithDivergentLocalContent(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "docs/guide.md", "hub version")
cycle(t, a)
b := newDevice(t, "devb", be)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, "docs/guide.md", "stale local version")
if res := cycle(t, b); res.Adopted != 1 {
t.Fatalf("Adopted = %d, want 1", res.Adopted)
}
cycle(t, a)
cycle(t, b)
for _, s := range []*Session{a, b} {
if got := read(t, s.Folder, "docs/guide.md"); got != "hub version" {
t.Fatalf("%s guide.md = %q, want the project's version", s.Device.ID, got)
}
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("%s: joining made conflict copies: %v", s.Device.ID, c)
}
}
// The superseded content is still in history, on both devices.
for _, s := range []*Session{a, b} {
ops, err := s.Store.AllOps()
if err != nil {
t.Fatal(err)
}
var found bool
for _, op := range ops {
if op.Path == "docs/guide.md" && s.Store.HasBlob(op.Blob) {
if body, err := os.ReadFile(s.Store.BlobPath(op.Blob)); err == nil && string(body) == "stale local version" {
found = true
}
}
}
if !found {
t.Fatalf("%s: the joiner's version is not recoverable from history", s.Device.ID)
}
}
}
// The reported connect experience: a folder that already holds a .bdriveignore
// (`bdrive init` seeds one) and an agent-written AGENTS.md joins a project that
// has its own versions of both. Neither may fork into a
// `.bdriveignore.bdrive-conflict-<device>-<time>` file, and neither may have the
// joiner's copy overwrite the team's. A real concurrent edit AFTER the join
// still conflict-copies — that is a different situation and keeps its old
// behavior.
func TestConnectDoesNotForkIgnoreAndAgentsFiles(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, ".bdriveignore", "node_modules/\n*.log\n")
write(t, a.Folder, "AGENTS.md", "team instructions\n")
cycle(t, a)
b := newDevice(t, "devb", be)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, ".bdriveignore", "# BearDrive starter\n.DS_Store\n")
write(t, b.Folder, "AGENTS.md", "notes my agent wrote here\n")
res := cycle(t, b)
if res.Adopted != 2 {
t.Fatalf("Adopted = %d, want 2 (.bdriveignore and AGENTS.md)", res.Adopted)
}
if res.Conflicts != 0 {
t.Fatalf("connecting made %d conflict copies, want 0", res.Conflicts)
}
cycle(t, a)
cycle(t, b)
for _, s := range []*Session{a, b} {
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("%s: connecting littered the folder: %v", s.Device.ID, c)
}
if got := read(t, s.Folder, "AGENTS.md"); got != "team instructions\n" {
t.Fatalf("%s AGENTS.md = %q, want the team's version", s.Device.ID, got)
}
if got := read(t, s.Folder, ".bdriveignore"); got != "node_modules/\n*.log\n" {
t.Fatalf("%s .bdriveignore = %q, want the team's version", s.Device.ID, got)
}
}
// Now a genuine concurrent edit between two devices that share the project.
write(t, a.Folder, "AGENTS.md", "a's edit\n")
write(t, b.Folder, "AGENTS.md", "b's edit\n")
cycle(t, a)
if res := cycle(t, b); res.Conflicts != 1 {
t.Fatalf("a real concurrent edit made %d conflict copies, want 1", res.Conflicts)
}
}
// The follower-brain topology: a personal folder is a mount on one project
// while a team knowledge folder nested inside it is a mount on another.
// Each project sees only its own files, in both directions, even as both
// actively sync.
func TestNestedMountSyncsIndependently(t *testing.T) {
personal := sharedRemote(t)
team := sharedRemote(t)
// Alice: personal brain mount with a nested team mount inside it.
aliceRoot := t.TempDir()
alice := deviceAt(t, "alice", aliceRoot, personal)
aliceTeam := deviceAt(t, "alice-team", filepath.Join(aliceRoot, "team"), team)
write(t, aliceRoot, "private.md", "my captures")
write(t, aliceRoot, "team/.bdrive/config.json", `{"mount_id":"m-team"}`)
write(t, aliceRoot, "team/plan.md", "roadmap v1")
cycle(t, alice)
cycle(t, aliceTeam)
// Bob syncs only the team project; Alice's laptop syncs only personal.
bobTeam := newDevice(t, "bob-team", team)
cycle(t, bobTeam)
if got := read(t, bobTeam.Folder, "plan.md"); got != "roadmap v1" {
t.Fatalf("team plan.md = %q, want roadmap v1", got)
}
if _, err := os.Stat(filepath.Join(bobTeam.Folder, "private.md")); !os.IsNotExist(err) {
t.Fatal("personal file leaked into the team project")
}
laptop := newDevice(t, "alice-laptop", personal)
cycle(t, laptop)
if got := read(t, laptop.Folder, "private.md"); got != "my captures" {
t.Fatalf("private.md = %q, want my captures", got)
}
if _, err := os.Stat(filepath.Join(laptop.Folder, "team")); !os.IsNotExist(err) {
t.Fatal("team folder leaked into the personal project")
}
// Bob's team edit reaches Alice's nested mount; her personal project
// must not journal the change it can see on disk.
write(t, bobTeam.Folder, "plan.md", "roadmap v2")
cycle(t, bobTeam)
cycle(t, aliceTeam)
if got := read(t, aliceRoot, "team/plan.md"); got != "roadmap v2" {
t.Fatalf("alice team/plan.md = %q, want roadmap v2", got)
}
if res := cycle(t, alice); res.LocalOps != 0 {
t.Fatalf("personal mount journaled %d ops for nested team content, want 0", res.LocalOps)
}
}
// The conflict-copy filename must keep matching the glob documented for the
// OKF validation hook (SKILL.md): validate alone can't see conflict copies,
// so agents check `*.bdrive-conflict-*` — renaming the pattern breaks them.
func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) {
name := conflictName("Wiki/page.md", "Snow's MacBook", time.Now())
ok, err := filepath.Match("*.bdrive-conflict-*", filepath.Base(name))
if err != nil || !ok {
t.Fatalf("conflict copy %q no longer matches the documented glob *.bdrive-conflict-*", name)
}
if strings.ContainsAny(filepath.Base(name), " '") {
t.Fatalf("conflict copy name %q should sanitize device names", name)
}
}
// Session-linked notes: a note set for the session (explicitly, or persisted
// in the store by `bdrive sync --note`) is stamped onto every op that scan
// commits, travels through the remote to peers, and expires with its TTL —
// so history can link each change to the agent session that made it.
func TestSessionNoteStampsOps(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// Explicit note on the session (one-shot `bdrive sync --note`).
a.Note = "claude-code session s-123"
write(t, a.Folder, "wiki/plan.md", "v1")
cycle(t, a)
cycle(t, b)
ops, err := b.Store.DeviceOps("deva")
if err != nil || len(ops) != 1 {
t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops))
}
if ops[0].Note != "claude-code session s-123" {
t.Fatalf("note on peer = %q", ops[0].Note)
}
// Persisted note: an empty Session.Note falls back to the store note, the
// path the daemon takes after a one-shot sync persisted it.
a.Note = ""
if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil {
t.Fatal(err)
}
write(t, a.Folder, "wiki/plan.md", "v2")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "claude-code session s-456" {
t.Fatalf("persisted-note op = %q", got)
}
// Deletes carry the note too.
if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil {
t.Fatal(err)
}
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
last := ops[len(ops)-1]
if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" {
t.Fatalf("delete op = %+v", last)
}
// An expired note stops applying: later edits stay unlabeled.
if err := a.Store.SaveNote("stale", time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
write(t, a.Folder, "wiki/new.md", "x")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "" {
t.Fatalf("expired note leaked onto op: %q", got)
}
// Clearing removes the note file entirely.
if err := a.Store.SaveNote("", 0); err != nil {
t.Fatal(err)
}
if got := a.Store.LoadNote(); got != "" {
t.Fatalf("cleared note = %q", got)
}
}
// A template is inert files, so it has to converge like any other content —
// and the double-seed case has to be a no-op rather than a divergence. One
// device seeds a structure, a teammate connects and gets it byte-for-byte;
// then the teammate seeds the same template again (an agent that asked when
// it shouldn't have) and nothing forks: no second copy, no conflict copies.
func TestTemplateSeedConverges(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
tpl, err := templates.Get("docs")
if err != nil {
t.Fatal(err)
}
if _, err := tpl.WriteTo(a.Folder); err != nil {
t.Fatal(err)
}
cycle(t, a)
cycle(t, b)
for _, f := range tpl.Files {
if got := read(t, b.Folder, f.Path); got != f.Content {
t.Fatalf("%s did not reach devb intact:\n%q", f.Path, got)
}
}
// devb seeds the same template on top of what it just pulled. WriteTo
// skips every existing path, so this writes nothing at all.
wrote, err := tpl.WriteTo(b.Folder)
if err != nil {
t.Fatal(err)
}
if len(wrote) != 0 {
t.Fatalf("re-seeding an already-seeded folder wrote %v", wrote)
}
cycle(t, b)
cycle(t, a)
for _, d := range []*Session{a, b} {
if got := conflictFiles(t, d.Folder); len(got) != 0 {
t.Fatalf("%s: double-seed made conflict copies: %v", d.Device.ID, got)
}
for _, f := range tpl.Files {
if read(t, d.Folder, f.Path) != f.Content {
t.Fatalf("%s: %s diverged after the second seed", d.Device.ID, f.Path)
}
}
}
// One op per file per device that wrote it: no path was journaled twice.
ops, err := a.Store.DeviceOps("deva")
if err != nil {
t.Fatal(err)
}
if len(ops) != len(tpl.Files) {
t.Fatalf("deva journaled %d ops for a %d-file template: %+v", len(ops), len(tpl.Files), ops)
}
if ops, err := b.Store.DeviceOps("devb"); err != nil || len(ops) != 0 {
t.Fatalf("devb journaled %d ops for files it only pulled (err %v)", len(ops), err)
}
}