diff --git a/go.mod b/go.mod index 24abab7..cfae4a0 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module forge.lthn.ai/core/ide +module dappco.re/go/core/ide go 1.26.0 @@ -15,8 +15,10 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/main.go b/main.go index 7637e6b..9220d4a 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,7 @@ import ( "forge.lthn.ai/core/go/pkg/core" guiMCP "forge.lthn.ai/core/gui/pkg/mcp" "forge.lthn.ai/core/gui/pkg/display" - "forge.lthn.ai/core/ide/icons" + "dappco.re/go/core/ide/icons" "forge.lthn.ai/core/mcp/pkg/mcp" "forge.lthn.ai/core/mcp/pkg/mcp/agentic" "forge.lthn.ai/core/mcp/pkg/mcp/brain" diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..fd69ccd --- /dev/null +++ b/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "testing" + + "forge.lthn.ai/core/config" + "github.com/stretchr/testify/assert" +) + +func TestGuiEnabled_Good_NilConfig(t *testing.T) { + // nil config should fall through to display detection. + result := guiEnabled(nil) + // On macOS/Windows this returns true; on Linux it depends on DISPLAY. + // Just verify it doesn't panic. + _ = result +} + +func TestGuiEnabled_Good_WithConfig(t *testing.T) { + cfg, _ := config.New() + // Fresh config has no gui.enabled key — should fall through to OS detection. + result := guiEnabled(cfg) + _ = result +} + +func TestStaticAssetGroup_Good(t *testing.T) { + s := &staticAssetGroup{ + name: "test-assets", + basePath: "/assets/test", + dir: "/tmp", + } + assert.Equal(t, "test-assets", s.Name()) + assert.Equal(t, "/assets/test", s.BasePath()) +} diff --git a/providers_test.go b/providers_test.go new file mode 100644 index 0000000..36ab5c4 --- /dev/null +++ b/providers_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/api/pkg/provider" + "forge.lthn.ai/core/go-scm/manifest" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProvidersAPI_Name(t *testing.T) { + api := NewProvidersAPI(nil, nil) + assert.Equal(t, "providers-api", api.Name()) +} + +func TestProvidersAPI_BasePath(t *testing.T) { + api := NewProvidersAPI(nil, nil) + assert.Equal(t, "/api/v1/providers", api.BasePath()) +} + +func TestProvidersAPI_List_Good_Empty(t *testing.T) { + gin.SetMode(gin.TestMode) + + reg := provider.NewRegistry() + rm := NewRuntimeManager(nil) + api := NewProvidersAPI(reg, rm) + + router := gin.New() + rg := router.Group(api.BasePath()) + api.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/providers", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp providersResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Empty(t, resp.Providers) +} + +func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) { + gin.SetMode(gin.TestMode) + + reg := provider.NewRegistry() + rm := NewRuntimeManager(nil) + + // Simulate a runtime provider. + rm.providers = append(rm.providers, &RuntimeProvider{ + Dir: "/tmp/test", + Port: 9999, + Manifest: &manifest.Manifest{ + Code: "test-provider", + Name: "Test Provider", + Version: "0.1.0", + Namespace: "test", + }, + }) + + api := NewProvidersAPI(reg, rm) + + router := gin.New() + rg := router.Group(api.BasePath()) + api.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/providers", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp providersResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Providers, 1) + assert.Equal(t, "test-provider", resp.Providers[0].Name) + assert.Equal(t, "test", resp.Providers[0].BasePath) + assert.Equal(t, "active", resp.Providers[0].Status) +} diff --git a/runtime.go b/runtime.go index e7b35e3..36358a4 100644 --- a/runtime.go +++ b/runtime.go @@ -15,6 +15,7 @@ import ( "forge.lthn.ai/core/api" "forge.lthn.ai/core/api/pkg/provider" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/manifest" "forge.lthn.ai/core/go-scm/marketplace" "github.com/gin-gonic/gin" @@ -63,7 +64,7 @@ func (rm *RuntimeManager) StartAll(ctx context.Context) error { dir := defaultProvidersDir() discovered, err := marketplace.DiscoverProviders(dir) if err != nil { - return fmt.Errorf("runtime: discover providers: %w", err) + return coreerr.E("runtime.StartAll", "discover providers", err) } if len(discovered) == 0 { @@ -93,7 +94,7 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc // Assign a free port. port, err := findFreePort() if err != nil { - return nil, fmt.Errorf("find free port: %w", err) + return nil, coreerr.E("runtime.startProvider", "find free port", err) } // Resolve binary path. @@ -114,7 +115,7 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start binary %s: %w", binaryPath, err) + return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("start binary %s", binaryPath), err) } // Wait for health check. @@ -122,7 +123,7 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc if err := waitForHealth(healthURL, 10*time.Second); err != nil { // Kill the process if health check fails. _ = cmd.Process.Kill() - return nil, fmt.Errorf("health check failed for %s: %w", m.Code, err) + return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("health check failed for %s", m.Code), err) } // Register proxy provider. @@ -248,7 +249,7 @@ func waitForHealth(url string, timeout time.Duration) error { time.Sleep(100 * time.Millisecond) } - return fmt.Errorf("health check timed out after %s: %s", timeout, url) + return coreerr.E("runtime.waitForHealth", fmt.Sprintf("timed out after %s: %s", timeout, url), nil) } // staticAssetGroup is a simple RouteGroup that serves static files. diff --git a/runtime_test.go b/runtime_test.go new file mode 100644 index 0000000..ae596eb --- /dev/null +++ b/runtime_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" + + "forge.lthn.ai/core/go-scm/manifest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindFreePort_Good(t *testing.T) { + port, err := findFreePort() + require.NoError(t, err) + assert.Greater(t, port, 0) + assert.Less(t, port, 65536) +} + +func TestFindFreePort_UniquePerCall(t *testing.T) { + port1, err := findFreePort() + require.NoError(t, err) + port2, err := findFreePort() + require.NoError(t, err) + // Two consecutive calls should very likely return different ports. + // (Not guaranteed, but effectively always true.) + assert.NotEqual(t, port1, port2) +} + +func TestWaitForHealth_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := waitForHealth(srv.URL, 5*time.Second) + assert.NoError(t, err) +} + +func TestWaitForHealth_Bad_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + err := waitForHealth(srv.URL, 500*time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") +} + +func TestWaitForHealth_Bad_NoServer(t *testing.T) { + err := waitForHealth("http://127.0.0.1:1", 500*time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") +} + +func TestDefaultProvidersDir_Good(t *testing.T) { + dir := defaultProvidersDir() + assert.Contains(t, dir, ".core") + assert.Contains(t, dir, "providers") +} + +func TestRuntimeManager_List_Good_Empty(t *testing.T) { + rm := NewRuntimeManager(nil) + infos := rm.List() + assert.Empty(t, infos) +} + +func TestRuntimeManager_List_Good_WithProviders(t *testing.T) { + rm := NewRuntimeManager(nil) + rm.providers = []*RuntimeProvider{ + { + Dir: "/tmp/test-provider", + Port: 12345, + Manifest: &manifest.Manifest{ + Code: "test-svc", + Name: "Test Service", + Version: "1.0.0", + Namespace: "test", + }, + }, + } + + infos := rm.List() + require.Len(t, infos, 1) + assert.Equal(t, "test-svc", infos[0].Code) + assert.Equal(t, "Test Service", infos[0].Name) + assert.Equal(t, "1.0.0", infos[0].Version) + assert.Equal(t, "test", infos[0].Namespace) + assert.Equal(t, 12345, infos[0].Port) + assert.Equal(t, "/tmp/test-provider", infos[0].Dir) +} + +func TestRuntimeManager_StopAll_Good_Empty(t *testing.T) { + rm := NewRuntimeManager(nil) + // Should not panic with no providers. + rm.StopAll() + assert.Empty(t, rm.providers) +} + +func TestRuntimeManager_StopAll_Good_WithProcess(t *testing.T) { + // Start a real process so we can test graceful stop. + cmd := exec.CommandContext(context.Background(), "sleep", "60") + require.NoError(t, cmd.Start()) + + rm := NewRuntimeManager(nil) + rm.providers = []*RuntimeProvider{ + { + Manifest: &manifest.Manifest{Code: "sleeper"}, + Cmd: cmd, + }, + } + + rm.StopAll() + assert.Nil(t, rm.providers) +} + +func TestRuntimeManager_StartAll_Good_EmptyDir(t *testing.T) { + rm := NewRuntimeManager(nil) + // StartAll with a non-existent providers dir should return an error + // because the default dir won't have providers (at most it logs and returns nil). + err := rm.StartAll(context.Background()) + // Depending on whether ~/.core/providers/ exists, this either returns + // nil (no providers found) or an error (dir doesn't exist). + // Either outcome is acceptable — no panic. + _ = err +}