|
| 1 | +package cellstore |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "io/fs" |
| 8 | + "os" |
| 9 | + "path/filepath" |
| 10 | + "runtime" |
| 11 | + "strings" |
| 12 | + "sync" |
| 13 | + "testing" |
| 14 | + |
| 15 | + "github.com/Agent-Field/codeaf/internal/cell" |
| 16 | + "github.com/Agent-Field/codeaf/internal/executor" |
| 17 | +) |
| 18 | + |
| 19 | +// useHome makes dir the home folder this process reports. |
| 20 | +func useHome(t *testing.T, dir string) { |
| 21 | + t.Helper() |
| 22 | + t.Setenv("HOME", dir) |
| 23 | + if runtime.GOOS == "windows" { |
| 24 | + t.Setenv("USERPROFILE", dir) |
| 25 | + } |
| 26 | +} |
| 27 | + |
| 28 | +// fillHome writes files spread over dirs folders under root. |
| 29 | +func fillHome(t testing.TB, root string, dirs, files int) { |
| 30 | + t.Helper() |
| 31 | + for d := 0; d < dirs; d++ { |
| 32 | + dir := filepath.Join(root, fmt.Sprintf("d%03d", d)) |
| 33 | + if err := os.MkdirAll(dir, 0o700); err != nil { |
| 34 | + t.Fatal(err) |
| 35 | + } |
| 36 | + for f := 0; f < files; f++ { |
| 37 | + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%04d.txt", f)), []byte("x"), 0o600); err != nil { |
| 38 | + t.Fatal(err) |
| 39 | + } |
| 40 | + } |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +// planLedger counts what the engine was asked to plan and what it planned. |
| 45 | +type planLedger struct { |
| 46 | + mu sync.Mutex |
| 47 | + trees []string // the tree of every capture-plan asked for |
| 48 | + paths int // the paths every plan answered with, summed |
| 49 | +} |
| 50 | + |
| 51 | +func (l *planLedger) over(inner Transport) Transport { |
| 52 | + return transportFunc(func(ctx context.Context, target Target, op Op) ([]byte, error) { |
| 53 | + out, err := inner.Do(ctx, target, op) |
| 54 | + if op.Verb() == "capture-plan" { |
| 55 | + var plan capturePlan |
| 56 | + _ = json.Unmarshal(out, &plan) |
| 57 | + l.mu.Lock() |
| 58 | + l.trees = append(l.trees, target.Tree) |
| 59 | + l.paths += len(plan.Paths) |
| 60 | + l.mu.Unlock() |
| 61 | + } |
| 62 | + return out, err |
| 63 | + }) |
| 64 | +} |
| 65 | + |
| 66 | +func (l *planLedger) asked(tree string) int { |
| 67 | + l.mu.Lock() |
| 68 | + defer l.mu.Unlock() |
| 69 | + n := 0 |
| 70 | + for _, asked := range l.trees { |
| 71 | + if asked == canonicalTree(tree) { |
| 72 | + n++ |
| 73 | + } |
| 74 | + } |
| 75 | + return n |
| 76 | +} |
| 77 | + |
| 78 | +// fakePlans answers capture-plan with an empty full plan and every other verb |
| 79 | +// with a snapshot, so the rule is seen without an engine binary. |
| 80 | +func fakePlans() Transport { |
| 81 | + return transportFunc(func(_ context.Context, _ Target, op Op) ([]byte, error) { |
| 82 | + if op.Verb() == "capture-plan" { |
| 83 | + return []byte(`{"token":"t","full":true,"paths":[]}`), nil |
| 84 | + } |
| 85 | + return []byte(`{"snapshot":"s"}`), nil |
| 86 | + }) |
| 87 | +} |
| 88 | + |
| 89 | +// A FOLDER THAT HOLDS THE HOME FOLDER IS NOT A PROJECT. A chat opened there |
| 90 | +// still seals (its own folder), but the engine is never asked to plan the home, |
| 91 | +// its parent or a filesystem root, while a project folder beside the home is |
| 92 | +// planned exactly as before. |
| 93 | +func TestAFolderHoldingHomeIsNeverPlanned(t *testing.T) { |
| 94 | + base := canonicalTree(t.TempDir()) |
| 95 | + house := filepath.Join(base, "people", "someone") |
| 96 | + project := filepath.Join(base, "people", "someone-else", "api") |
| 97 | + for _, dir := range []string{house, project} { |
| 98 | + if err := os.MkdirAll(dir, 0o700); err != nil { |
| 99 | + t.Fatal(err) |
| 100 | + } |
| 101 | + } |
| 102 | + useHome(t, house) |
| 103 | + root := filepath.VolumeName(base) + string(filepath.Separator) |
| 104 | + for _, tc := range []struct { |
| 105 | + name, workspace string |
| 106 | + planned bool |
| 107 | + }{ |
| 108 | + {"home", house, false}, |
| 109 | + {"home with a trailing separator", house + string(filepath.Separator), false}, |
| 110 | + {"parent of home", filepath.Dir(house), false}, |
| 111 | + {"filesystem root", root, false}, |
| 112 | + {"project beside home", project, true}, |
| 113 | + } { |
| 114 | + t.Run(tc.name, func(t *testing.T) { |
| 115 | + c := newCell(t) |
| 116 | + ledger := &planLedger{} |
| 117 | + e := EngineFor(tc.workspace) |
| 118 | + e.DataRoot, e.Transport = t.TempDir(), ledger.over(fakePlans()) |
| 119 | + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { |
| 120 | + t.Fatal(err) |
| 121 | + } |
| 122 | + if got := ledger.asked(tc.workspace); (got > 0) != tc.planned { |
| 123 | + t.Fatalf("plans of %s = %d, want planned=%v (asked for %v)", tc.workspace, got, tc.planned, ledger.trees) |
| 124 | + } |
| 125 | + if !tc.planned && ledger.asked(c.Root) == 0 { |
| 126 | + t.Fatalf("the chat's own folder was not sealed instead (asked for %v)", ledger.trees) |
| 127 | + } |
| 128 | + }) |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +// The seat says once, in plain words, that a home folder's files are not kept. |
| 133 | +func TestAHomeSeatSaysOnceThatItsFilesAreNotSaved(t *testing.T) { |
| 134 | + // The seat is built over a fake transport, so no engine binary is needed; |
| 135 | + // without this a build with no engine inside gets a plain seat and no notice. |
| 136 | + t.Cleanup(cell.UseAvailabilityInTests(true)) |
| 137 | + house := t.TempDir() |
| 138 | + useHome(t, house) |
| 139 | + for _, tc := range []struct { |
| 140 | + workspace string |
| 141 | + notices int |
| 142 | + }{{house, 1}, {t.TempDir(), 0}} { |
| 143 | + var said []string |
| 144 | + _, err := SeatOver(executor.HostBound, newCell(t), tc.workspace, nil, nil, nil, |
| 145 | + WithNotices(func(s string) { said = append(said, s) }), |
| 146 | + func(e *Engine) { e.DataRoot, e.Transport = t.TempDir(), fakePlans() }) |
| 147 | + if err != nil { |
| 148 | + t.Fatal(err) |
| 149 | + } |
| 150 | + home := 0 |
| 151 | + for _, s := range said { |
| 152 | + if strings.Contains(s, "home folder") { |
| 153 | + home++ |
| 154 | + } |
| 155 | + } |
| 156 | + if home != tc.notices { |
| 157 | + t.Fatalf("workspace %s: home notices = %d, want %d (%q)", tc.workspace, home, tc.notices, said) |
| 158 | + } |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +// TestStartupInALargeHomeWalksNoHome is the startup gate: a seat opened with |
| 163 | +// warm capture in a home of many files, through the real engine, finishes its |
| 164 | +// first capture having planned none of the home's files and having written no |
| 165 | +// plan that lists them. It counts paths and plan bytes; it never times. |
| 166 | +func TestStartupInALargeHomeWalksNoHome(t *testing.T) { |
| 167 | + const dirs, files = 100, 200 // 20,000 files |
| 168 | + house := t.TempDir() |
| 169 | + fillHome(t, house, dirs, files) |
| 170 | + useHome(t, house) |
| 171 | + real := realEngine(t) |
| 172 | + ledger := &planLedger{} |
| 173 | + c := newCell(t) |
| 174 | + seat, err := SeatOver(executor.HostBound, c, house, nil, func(err error) { |
| 175 | + if err != nil { |
| 176 | + t.Errorf("seal: %v", err) |
| 177 | + } |
| 178 | + }, nil, WithPipeline(), WithWarmCapture(), WithNotices(func(string) {}), func(e *Engine) { |
| 179 | + e.Binary, e.DataRoot, e.Transport = real.Binary, real.DataRoot, ledger.over(real.Transport) |
| 180 | + }) |
| 181 | + if err != nil { |
| 182 | + t.Fatal(err) |
| 183 | + } |
| 184 | + if err := executor.Drain(t.Context(), seat); err != nil { |
| 185 | + t.Fatal(err) |
| 186 | + } |
| 187 | + if head, err := Head(c); err != nil || head == nil { |
| 188 | + t.Fatalf("the chat was not sealed: %v", err) |
| 189 | + } |
| 190 | + if n := ledger.asked(house); n != 0 { |
| 191 | + t.Fatalf("the home was planned %d times", n) |
| 192 | + } |
| 193 | + if ledger.paths >= dirs*files/10 { |
| 194 | + t.Fatalf("startup planned %d paths for a home of %d files", ledger.paths, dirs*files) |
| 195 | + } |
| 196 | + var planBytes int64 |
| 197 | + _ = filepath.WalkDir(real.DataRoot, func(path string, d fs.DirEntry, err error) error { |
| 198 | + if err == nil && !d.IsDir() && strings.Contains(filepath.ToSlash(path), "/capture-plans/") { |
| 199 | + if info, err := d.Info(); err == nil { |
| 200 | + planBytes += info.Size() |
| 201 | + } |
| 202 | + } |
| 203 | + return nil |
| 204 | + }) |
| 205 | + // One plan line per home file would be at least ten bytes each. |
| 206 | + if planBytes >= int64(dirs*files) { |
| 207 | + t.Fatalf("startup wrote %d bytes of capture plans for a home of %d files", planBytes, dirs*files) |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +// A CHAT THAT LEFT ITS FOLDER'S FILES UNSAVED ARRIVES THAT WAY. A chat sealed in |
| 212 | +// its sender's home folder carries only its own folder. On a machine whose home |
| 213 | +// is elsewhere, the sender's home path may be an ordinary folder that exists |
| 214 | +// (somebody else's files, or the same path by coincidence). The receiver must |
| 215 | +// still seal and restore only the chat's own folder, never that path. |
| 216 | +func TestAHomeChatNeverCapturesTheSendersHomeOnAnotherMachine(t *testing.T) { |
| 217 | + base := canonicalTree(t.TempDir()) |
| 218 | + sender, receiver := filepath.Join(base, "sender"), filepath.Join(base, "receiver") |
| 219 | + for _, dir := range []string{sender, receiver} { |
| 220 | + if err := os.MkdirAll(dir, 0o700); err != nil { |
| 221 | + t.Fatal(err) |
| 222 | + } |
| 223 | + } |
| 224 | + c := newCell(t) |
| 225 | + useHome(t, sender) |
| 226 | + e := EngineFor(sender) |
| 227 | + e.DataRoot, e.Transport = t.TempDir(), fakePlans() |
| 228 | + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { |
| 229 | + t.Fatal(err) |
| 230 | + } |
| 231 | + // The same chat, its .cell/ as it arrived, on a machine whose home is not |
| 232 | + // the sender's: the session record still names the sender's path. |
| 233 | + useHome(t, receiver) |
| 234 | + ledger := &planLedger{} |
| 235 | + there := EngineFor(sender) |
| 236 | + there.DataRoot, there.Transport = t.TempDir(), ledger.over(fakePlans()) |
| 237 | + if _, err := there.Seal(t.Context(), c, TurnInfo{}); err != nil { |
| 238 | + t.Fatal(err) |
| 239 | + } |
| 240 | + if n := ledger.asked(sender); n != 0 { |
| 241 | + t.Fatalf("the receiver planned the sender's home path %d times (asked for %v)", n, ledger.trees) |
| 242 | + } |
| 243 | + if got := there.tree(c); got != canonicalTree(c.Root) { |
| 244 | + t.Fatalf("the receiver restores into %s, want the chat's own folder %s", got, c.Root) |
| 245 | + } |
| 246 | + // A work/ of the chat's own is still the chat's, and on the sender, where the |
| 247 | + // path is home, the path stays the chat's working folder. |
| 248 | + if ForeignFolder(c.Root, filepath.Join(c.Root, "work")) { |
| 249 | + t.Fatal("the chat's own work/ is called foreign") |
| 250 | + } |
| 251 | + useHome(t, sender) |
| 252 | + if ForeignFolder(c.Root, sender) { |
| 253 | + t.Fatal("the sender's own home is called foreign on the sender") |
| 254 | + } |
| 255 | +} |
0 commit comments