memory_offline_test.go
1 //go:build !plan9 2 3 package e2e 4 5 import ( 6 "context" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "testing" 11 "time" 12 13 "github.com/Kocoro-lab/ShanClaw/internal/memory" 14 ) 15 16 // TestMemoryOffline_DisabledProviderFastPath validates the disabled-provider 17 // fast path: NewService + Start should land in StatusDisabled without 18 // attempting to spawn anything, and Query should return ClassUnavailable so 19 // the memory_recall tool falls back instead of erroring. 20 func TestMemoryOffline_DisabledProviderFastPath(t *testing.T) { 21 svc := memory.NewService(memory.Config{Provider: "disabled"}, nil) 22 if err := svc.Start(context.Background()); err != nil { 23 t.Fatalf("Start: %v", err) 24 } 25 if svc.Status() != memory.StatusDisabled { 26 t.Fatalf("status=%v want StatusDisabled", svc.Status()) 27 } 28 _, class, err := svc.Query(context.Background(), memory.QueryIntent{ 29 Mode: memory.ModeDirectRelation, 30 AnchorMentions: []string{"x"}, 31 }) 32 if err != nil { 33 t.Fatalf("Query err=%v want nil", err) 34 } 35 if class != memory.ClassUnavailable { 36 t.Fatalf("class=%v want ClassUnavailable", class) 37 } 38 } 39 40 // TestMemoryOffline_SidecarSpawnAndAttachPolicy spawns a fake sidecar (a 41 // python3 script that binds the UDS and serves /health=ready), waits for it 42 // to come up via the same path the daemon uses (Sidecar.Spawn + WaitReady), 43 // then validates AttachPolicy returns ready=true and the UDS Client can 44 // reach the socket end-to-end. This is the integration smoke that proves the 45 // sidecar lifecycle wiring works on a developer machine. 46 func TestMemoryOffline_SidecarSpawnAndAttachPolicy(t *testing.T) { 47 if _, err := exec.LookPath("python3"); err != nil { 48 t.Skip("python3 unavailable; sidecar smoke requires python3") 49 } 50 51 // Short paths to dodge the macOS UDS sun_path 104-byte limit. Avoid 52 // t.TempDir() for the socket — it nests under the per-test path which 53 // can blow past the limit. 54 sockDir, err := os.MkdirTemp("", "e2esock") 55 if err != nil { 56 t.Fatal(err) 57 } 58 defer os.RemoveAll(sockDir) 59 sock := filepath.Join(sockDir, "s") 60 61 pyDir, err := os.MkdirTemp("", "e2epy") 62 if err != nil { 63 t.Fatal(err) 64 } 65 defer os.RemoveAll(pyDir) 66 scriptPath := filepath.Join(pyDir, "fake_tlm.py") 67 script := `import sys, os, json, http.server, socketserver 68 sock_path = sys.argv[sys.argv.index('--socket')+1] 69 try: os.unlink(sock_path) 70 except FileNotFoundError: pass 71 class H(http.server.BaseHTTPRequestHandler): 72 def do_GET(self): 73 if self.path == '/health': 74 body = json.dumps({'ready': True, 'protocol_version': 1}).encode() 75 self.send_response(200); self.send_header('Content-Type','application/json'); self.end_headers(); self.wfile.write(body) 76 def log_message(self, *args, **kwargs): pass 77 class UDSServer(socketserver.UnixStreamServer): 78 allow_reuse_address = True 79 srv = UDSServer(sock_path, H) 80 srv.serve_forever() 81 ` 82 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { 83 t.Fatal(err) 84 } 85 86 rootDir, err := os.MkdirTemp("", "e2eroot") 87 if err != nil { 88 t.Fatal(err) 89 } 90 defer os.RemoveAll(rootDir) 91 92 cfg := memory.Config{ 93 TLMPath: "python3", 94 SocketPath: sock, 95 BundleRoot: rootDir, 96 SidecarReadyTimeout: 5 * time.Second, 97 SidecarShutdownGrace: 2 * time.Second, 98 } 99 sidecar := memory.NewSidecar(cfg, []string{scriptPath}) 100 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 101 defer cancel() 102 if err := sidecar.Spawn(ctx); err != nil { 103 t.Fatalf("Spawn: %v", err) 104 } 105 defer sidecar.Shutdown(2 * time.Second) 106 if err := sidecar.WaitReady(ctx, 5*time.Second); err != nil { 107 t.Fatalf("WaitReady: %v", err) 108 } 109 110 // AttachPolicy from the CLI/TUI side should now succeed. 111 ready, err := memory.AttachPolicy(ctx, sock) 112 if err != nil { 113 t.Fatalf("AttachPolicy: %v", err) 114 } 115 if !ready { 116 t.Fatal("AttachPolicy returned ready=false against a live sidecar") 117 } 118 119 // Issue an actual /query through the UDS client to prove end-to-end 120 // reachability. The fake doesn't implement /query, so we don't expect 121 // ClassOK — we just want to prove the wire works (no transport error 122 // surfaces as ClassUnavailable; an HTTP error surfaces as Permanent / 123 // Retryable). Either non-OK class is acceptable. 124 c := memory.NewClient(sock, 5*time.Second) 125 _, class, _ := c.Query(ctx, memory.QueryIntent{ 126 Mode: memory.ModeDirectRelation, 127 AnchorMentions: []string{"x"}, 128 }) 129 if class == memory.ClassOK { 130 t.Logf("unexpected ClassOK from fake sidecar without /query handler; continuing") 131 } 132 } 133 134 // TestMemoryOffline_AttachPolicyMissingSocket validates that AttachPolicy 135 // against a non-existent socket returns ready=false (not an error). This is 136 // the path CLI/TUI hits when the daemon isn't running. 137 func TestMemoryOffline_AttachPolicyMissingSocket(t *testing.T) { 138 dir, err := os.MkdirTemp("", "noattach") 139 if err != nil { 140 t.Fatal(err) 141 } 142 defer os.RemoveAll(dir) 143 ready, err := memory.AttachPolicy(context.Background(), filepath.Join(dir, "missing")) 144 if err != nil { 145 t.Fatalf("AttachPolicy err=%v want nil", err) 146 } 147 if ready { 148 t.Fatal("AttachPolicy on missing socket returned ready=true") 149 } 150 }