Files

205 lines
6.3 KiB
Go
Raw Permalink Normal View History

package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/store"
)
// Real hook payload shapes from the supported platforms — read-log must find
// the file paths wherever each platform puts them. Shell and search events
// are mined heuristically, so the folder holds real files to stat against.
func TestExtractEventPaths(t *testing.T) {
folder := t.TempDir()
for _, f := range []string{"wiki/a.md", "wiki/b.md", "notes.md", "out.md"} {
p := filepath.Join(folder, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
abs := func(rel string) string { return filepath.Join(folder, rel) }
cases := map[string]struct {
payload string
want []string
}{
"claude read": {
`{"session_id":"abc","hook_event_name":"PostToolUse","tool_name":"Read",
"tool_input":{"file_path":"/proj/wiki/a.md"},"tool_response":{"type":"text"}}`,
[]string{"/proj/wiki/a.md"},
},
"gemini read_many_files": {
`{"session_id":"g1","tool":{"name":"read_many_files","args":{"paths":["wiki/a.md","wiki/b.md"]}}}`,
[]string{"wiki/a.md", "wiki/b.md"},
},
"gemini read_file absolute": {
`{"session_id":"g2","tool":{"name":"read_file","args":{"absolute_path":"/proj/notes.md"}}}`,
[]string{"/proj/notes.md"},
},
"hermes read_file": {
`{"hook_event_name":"post_tool_call","tool_name":"read_file","tool_args":{"path":"docs/x.md"}}`,
[]string{"docs/x.md"},
},
"duplicates collapse": {
`{"tool_input":{"file_path":"a.md"},"extra":{"file_path":"a.md"}}`,
[]string{"a.md"},
},
"no paths": {
`{"session_id":"abc","prompt":"hello"}`,
nil,
},
"not json": {
`plain text`,
nil,
},
// Shell commands: existing files named as arguments count as reads;
// flags, missing files, and redirect targets don't.
"bash cat and grep": {
`{"tool_name":"Bash","tool_input":{"command":"cat wiki/a.md && grep -n foo notes.md missing.md > out.md"}}`,
[]string{"wiki/a.md", "notes.md"},
},
"bash pipeline with quotes": {
`{"tool_name":"Bash","tool_input":{"command":"tail -20 'wiki/b.md' | head -5"}}`,
[]string{"wiki/b.md"},
},
// Search tools: reads are the files the MATCHES came from, mined
// from the response; the scope dir in tool_input.path must not leak.
"grep content lines": {
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
"tool_response":{"content":"wiki/a.md:3:foo bar\nwiki/b.md:9:foo"}}`,
[]string{"wiki/a.md", "wiki/b.md"},
},
"grep filenames list": {
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
"tool_response":{"filenames":["` + abs("notes.md") + `"]}}`,
[]string{abs("notes.md")},
},
// Listing tools: seeing a file's name is not reading it.
"glob ignored": {
`{"tool_name":"Glob","tool_input":{"pattern":"**/*.md"},
"tool_response":{"filenames":["` + abs("wiki/a.md") + `"]}}`,
nil,
},
}
for name, c := range cases {
got := extractEventPaths([]byte(c.payload), folder)
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("%s: paths = %v, want %v", name, got, c.want)
}
}
}
// End to end through the cobra command: a claude-style event lands in the
// mount's read spool, filtered to project-relative synced paths.
func TestReadLogCommand(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
if err != nil {
t.Fatal(err)
}
if _, _, err := config.EnrollMount(folder); err != nil { // enroll, as `bdrive init` would
t.Fatal(err)
}
c := readLogCmd()
c.SetIn(bytes.NewReader([]byte(`{"session_id":"abc","tool_name":"Read",
"tool_input":{"file_path":"` + folder + `/wiki/a.md"},
"other":{"file_path":"/somewhere/else/entirely.md"}}`)))
c.SetArgs([]string{folder})
if err := c.Execute(); err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
evs, err := st.PendingReads()
if err != nil {
t.Fatal(err)
}
if len(evs) != 1 || evs[0].Path != "wiki/a.md" {
t.Fatalf("spool = %+v, want just the in-project read, mount-relative", evs)
}
// The same session id `bdrive sync --hook` stamps onto the writes, off
// the same stdin payload — it is what lets the hub join this read to the
// run card that turn produced.
if evs[0].Session != "abc" {
t.Fatalf("spooled read Session = %q, want the event's session_id", evs[0].Session)
}
}
// read-log fires on every agent tool call in every folder, so it must be
// inert where this device never opted in: an unenrolled config.json (one
// that arrived with a git clone / copied folder) spools nothing, enrolls
// nothing, and creates no volume store; a paused project spools nothing.
func TestReadLogGated(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
if err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
event := `{"tool_name":"Read","tool_input":{"file_path":"` + folder + `/wiki/a.md"}}`
run := func() {
c := readLogCmd()
c.SetIn(bytes.NewReader([]byte(event)))
c.SetArgs([]string{folder})
if err := c.Execute(); err != nil {
t.Fatalf("read-log must never fail: %v", err)
}
}
// Unenrolled: no spool, no store, no registry entry.
run()
if _, err := os.Stat(vdir); !os.IsNotExist(err) {
t.Fatal("unenrolled read-log created the volume store")
}
mounts, err := config.LoadMounts()
if err != nil {
t.Fatal(err)
}
if _, enrolled := mounts[proj.ID]; enrolled {
t.Fatal("read-log enrolled the mount; only `bdrive init` may do that")
}
// Enrolled but paused by `bdrive stop`: still nothing spooled.
if _, _, err := config.EnrollMount(folder); err != nil {
t.Fatal(err)
}
if err := store.SetPaused(vdir, true); err != nil {
t.Fatal(err)
}
run()
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
evs, err := st.PendingReads()
if err != nil {
t.Fatal(err)
}
if len(evs) != 0 {
t.Fatalf("paused read-log spooled %+v, want nothing", evs)
}
}