mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Inbound sync was invisible to the machine it landed on — a local index, cache or notifier had to poll. A `post_sync` command in the folder's own .bdrive/config.json now runs once per cycle that applied peer changes, with the batch as JSON on stdin. The batch rides out on a new Result.Inbound rather than the inbound spool: DrainInbound is destructive and `bdrive sync --hook` is its only consumer, so a second drainer would silently empty the agent's "teammates changed X" context. Both are kept, and both comments now say why. Cycle becomes a thin wrapper over cycleLocked so the hook is spawned after the volume flock drops — a property of the code shape, not a rule each of the seven call sites has to remember. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
7.0 KiB
Go
244 lines
7.0 KiB
Go
package syncer
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
)
|
|
|
|
// postSync writes a folder's .bdrive/config.json with a post_sync command.
|
|
func postSync(t *testing.T, folder, cmd string) {
|
|
t.Helper()
|
|
if _, err := config.SaveProject(folder, config.Project{PostSync: cmd}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// waitForFile polls until path exists, because the hook is spawned detached:
|
|
// a bare assert right after cycle() races the child.
|
|
func waitForFile(t *testing.T, path string, d time.Duration) []byte {
|
|
t.Helper()
|
|
deadline := time.Now().Add(d)
|
|
for {
|
|
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
|
return b
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("post_sync marker %s never appeared", path)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// neverAppears is the negative of waitForFile: give the spawn a fair chance,
|
|
// then assert nothing ran.
|
|
func neverAppears(t *testing.T, path string) {
|
|
t.Helper()
|
|
time.Sleep(300 * time.Millisecond)
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Fatalf("post_sync ran but should not have (%s exists)", path)
|
|
}
|
|
}
|
|
|
|
func batch(t *testing.T, raw []byte) postSyncPayload {
|
|
t.Helper()
|
|
var p postSyncPayload
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
t.Fatalf("post_sync stdin is not the JSON batch: %v (%q)", err, raw)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// TestResultInboundNamesPeerPaths is step 1 on its own: the cycle carries the
|
|
// applied paths out on Result, and a local-edit-only cycle carries none.
|
|
func TestResultInboundNamesPeerPaths(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
|
|
write(t, a.Folder, "wiki/onboarding.md", "hello")
|
|
write(t, a.Folder, "notes/retired.md", "bye")
|
|
cycle(t, a)
|
|
|
|
res := cycle(t, b)
|
|
if len(res.Inbound) != 2 {
|
|
t.Fatalf("Result.Inbound = %v, want 2 writes", res.Inbound)
|
|
}
|
|
for _, e := range res.Inbound {
|
|
if e.Deleted {
|
|
t.Fatalf("%s reported as a delete, want a write", e.Path)
|
|
}
|
|
}
|
|
|
|
if err := os.Remove(filepath.Join(a.Folder, "notes", "retired.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, a)
|
|
res = cycle(t, b)
|
|
if len(res.Inbound) != 1 || !res.Inbound[0].Deleted || res.Inbound[0].Path != "notes/retired.md" {
|
|
t.Fatalf("Result.Inbound = %v, want one delete of notes/retired.md", res.Inbound)
|
|
}
|
|
|
|
// A cycle that only scans and pushes local work is not inbound.
|
|
write(t, b.Folder, "mine.md", "local only")
|
|
res = cycle(t, b)
|
|
if len(res.Inbound) != 0 {
|
|
t.Fatalf("local-edit cycle reported Inbound = %v, want none", res.Inbound)
|
|
}
|
|
}
|
|
|
|
// TestPostSyncFiresOncePerInboundBatch is the "400 files, one invocation" AC.
|
|
func TestPostSyncFiresOncePerInboundBatch(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
|
|
marker := filepath.Join(t.TempDir(), "marker.json")
|
|
postSync(t, b.Folder, "cat >> "+marker)
|
|
|
|
write(t, a.Folder, "one.md", "1")
|
|
write(t, a.Folder, "two.md", "2")
|
|
write(t, a.Folder, "wiki/three.md", "3")
|
|
cycle(t, a)
|
|
|
|
cycle(t, b)
|
|
got := batch(t, waitForFile(t, marker, 5*time.Second))
|
|
if len(got.Changed) != 3 {
|
|
t.Fatalf("batch = %+v, want 3 paths in ONE invocation", got.Changed)
|
|
}
|
|
for _, c := range got.Changed {
|
|
if c.Op != "write" {
|
|
t.Fatalf("%s op = %q, want write", c.Path, c.Op)
|
|
}
|
|
}
|
|
if got.Folder != b.Folder {
|
|
t.Fatalf("batch folder = %q, want %q", got.Folder, b.Folder)
|
|
}
|
|
if got.Project == "" {
|
|
t.Fatal("batch project is empty")
|
|
}
|
|
// >> appends, so a second invocation would show up as a longer file.
|
|
raw, err := os.ReadFile(marker)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(raw, new(postSyncPayload)); err != nil {
|
|
t.Fatalf("marker holds more than one batch: %q", raw)
|
|
}
|
|
}
|
|
|
|
// TestPostSyncSilentWithoutConfig: no post_sync key, no new behavior.
|
|
func TestPostSyncSilentWithoutConfig(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
|
|
postSync(t, b.Folder, "") // a project config, deliberately with no command
|
|
|
|
// Only a spawned command could create this.
|
|
marker := filepath.Join(t.TempDir(), "marker")
|
|
|
|
write(t, a.Folder, "one.md", "1")
|
|
cycle(t, a)
|
|
res := cycle(t, b)
|
|
if res.Materialized == 0 {
|
|
t.Fatal("nothing materialized; test proves nothing")
|
|
}
|
|
if len(res.Inbound) != 1 {
|
|
t.Fatalf("Result.Inbound = %v, want the one applied path", res.Inbound)
|
|
}
|
|
neverAppears(t, marker)
|
|
}
|
|
|
|
// TestPostSyncSkipsLocalOnlyCycle: scan + push is not an inbound event.
|
|
func TestPostSyncSkipsLocalOnlyCycle(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
b := newDevice(t, "devb", rem)
|
|
|
|
marker := filepath.Join(t.TempDir(), "marker")
|
|
postSync(t, b.Folder, "cat > "+marker)
|
|
|
|
write(t, b.Folder, "mine.md", "local only")
|
|
res := cycle(t, b)
|
|
if res.LocalOps == 0 {
|
|
t.Fatal("expected a local op; test proves nothing")
|
|
}
|
|
neverAppears(t, marker)
|
|
}
|
|
|
|
// TestPostSyncReportsDeletes: each entry distinguishes write from delete.
|
|
func TestPostSyncReportsDeletes(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
|
|
write(t, a.Folder, "gone.md", "here")
|
|
cycle(t, a)
|
|
cycle(t, b) // b now holds it
|
|
|
|
marker := filepath.Join(t.TempDir(), "marker.json")
|
|
postSync(t, b.Folder, "cat > "+marker)
|
|
|
|
if err := os.Remove(filepath.Join(a.Folder, "gone.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
got := batch(t, waitForFile(t, marker, 5*time.Second))
|
|
if len(got.Changed) != 1 || got.Changed[0].Path != "gone.md" || got.Changed[0].Op != "delete" {
|
|
t.Fatalf("batch = %+v, want one delete of gone.md", got.Changed)
|
|
}
|
|
}
|
|
|
|
// TestPostSyncFailureNeverBreaksCycle: a hook that exits non-zero or does not
|
|
// exist leaves the cycle and the next one alone.
|
|
func TestPostSyncFailureNeverBreaksCycle(t *testing.T) {
|
|
for _, cmd := range []string{"exit 3", "definitely-not-a-real-binary-xyz"} {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
postSync(t, b.Folder, cmd)
|
|
|
|
write(t, a.Folder, "one.md", "1")
|
|
cycle(t, a)
|
|
res, err := b.Cycle(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("%q: Cycle returned %v", cmd, err)
|
|
}
|
|
if res.Materialized != 1 || len(res.Inbound) != 1 {
|
|
t.Fatalf("%q: Result affected by the hook: %+v", cmd, res)
|
|
}
|
|
|
|
// The next cycle still converges.
|
|
write(t, a.Folder, "two.md", "2")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "two.md") != "2" {
|
|
t.Fatalf("%q: sync stopped converging after a failing hook", cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPostSyncLeavesInboundSpool is the guard on the trap: firing the hook
|
|
// must not consume what `bdrive sync --hook` reports to the agent.
|
|
func TestPostSyncLeavesInboundSpool(t *testing.T) {
|
|
rem := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
|
|
|
|
marker := filepath.Join(t.TempDir(), "marker.json")
|
|
postSync(t, b.Folder, "cat > "+marker)
|
|
|
|
write(t, a.Folder, "wiki/page.md", "hi")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
waitForFile(t, marker, 5*time.Second)
|
|
|
|
evs, err := b.Store.DrainInbound()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 1 || evs[0].Path != "wiki/page.md" {
|
|
t.Fatalf("inbound spool = %v, want wiki/page.md still queued for the agent hook", evs)
|
|
}
|
|
}
|