/ test / e2e / memory_offline_test.go
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  }