Files
beardrive/internal/syncer/postsync_test.go
T
edfe46c0aa post_sync: run a local command when teammates' changes land (#163)
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>
2026-08-13 11:04:23 -07:00

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