From 634bc1d0936cd6a4b7063e37c956c74a97af2754 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Mar 2026 08:48:45 +0800 Subject: [PATCH] Update test scope and remove obsolete E2E tests - Change the test scope identifier from "@test" to "@yaoagents" in client tests for consistency. - Remove the outdated E2E test file `e2e_test.go` from the manager directory, as it is no longer needed. - Enhance the agent manager to utilize the MCP manager for dependency installations, improving the handling of MCP-type dependencies. --- registry/client_test.go | 2 +- registry/manager/agent/add.go | 73 +-- registry/manager/agent/agent.go | 3 + registry/manager/agent_e2e_test.go | 464 ++++++++++++++++ registry/manager/e2e_helpers_test.go | 361 +++++++++++++ registry/manager/e2e_test.go | 770 --------------------------- registry/manager/mcp_e2e_test.go | 645 ++++++++++++++++++++++ registry/manager/robot_e2e_test.go | 256 +++++++++ 8 files changed, 1749 insertions(+), 825 deletions(-) create mode 100644 registry/manager/agent_e2e_test.go create mode 100644 registry/manager/e2e_helpers_test.go delete mode 100644 registry/manager/e2e_test.go create mode 100644 registry/manager/mcp_e2e_test.go create mode 100644 registry/manager/robot_e2e_test.go diff --git a/registry/client_test.go b/registry/client_test.go index 4940a8eb..534445ea 100644 --- a/registry/client_test.go +++ b/registry/client_test.go @@ -12,7 +12,7 @@ import ( ) const ( - testScope = "@test" + testScope = "@yaoagents" ) func serverURL() string { diff --git a/registry/manager/agent/add.go b/registry/manager/agent/add.go index cc826582..7963116f 100644 --- a/registry/manager/agent/add.go +++ b/registry/manager/agent/add.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/yaoapp/yao/registry/manager/common" + mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" ) // AddOptions configures the Add operation. @@ -69,6 +70,11 @@ func (m *Manager) Add(pkgID string, opts AddOptions) error { if err := m.installDependencies(manifest.Dependencies, lf, pkgID, map[string]bool{pkgID: true}); err != nil { return err } + // Reload lockfile — dependency managers write their own entries to disk + lf, err = common.LoadLockfile(m.appRoot) + if err != nil { + return err + } } // Unpack to destination @@ -113,6 +119,8 @@ func (m *Manager) Add(pkgID string, opts AddOptions) error { } // installDependencies recursively installs missing dependencies. +// For MCP-type dependencies, delegates to the MCP manager which handles +// script extraction to the project root correctly. func (m *Manager) installDependencies(deps map[string]string, lf *common.RegistryYao, parentID string, installing map[string]bool) error { missing, conflicts, _ := common.CheckDependencies(deps, lf) @@ -130,10 +138,8 @@ func (m *Manager) installDependencies(deps map[string]string, lf *common.Registr choice := m.prompter.Choose(msg, options) switch choice { case 0: - // Upgrade: treat as missing so it gets reinstalled missing = append(missing, c) case 1: - // Keep current continue default: return fmt.Errorf("installation aborted by user") @@ -144,7 +150,6 @@ func (m *Manager) installDependencies(deps map[string]string, lf *common.Registr return nil } - // Build summary var summary strings.Builder summary.WriteString("The following dependencies need to be installed:\n") for _, dep := range missing { @@ -160,63 +165,23 @@ func (m *Manager) installDependencies(deps map[string]string, lf *common.Registr } installing[dep.PackageID] = true - // Determine type from package ID by trying to pull and reading manifest - depScope, depName, err := common.ParsePackageID(dep.PackageID) - if err != nil { + if _, _, err := common.ParsePackageID(dep.PackageID); err != nil { return err } - // Try assistant type first, then mcp - var installed bool - for _, regType := range []string{common.TypeDirAssistants, common.TypeDirMCPs} { - zipData, digest, err := m.client.Pull(regType, "@"+depScope, depName, "latest") - if err != nil { - continue - } - manifest, err := common.ReadManifest(zipData) - if err != nil { - continue - } - - pkgType := manifest.Type - destDir := common.PackageDir(pkgType, depScope, depName, m.appRoot) - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - if _, err := common.UnpackTo(zipData, destDir); err != nil { - return fmt.Errorf("unpack dependency %s: %w", dep.PackageID, err) - } - - relDir := common.PackageDirRel(pkgType, depScope, depName) - files, err := common.HashDir(destDir, relDir) - if err != nil { - return err - } - - info := common.PackageInfo{ - Type: pkgType, - Version: manifest.Version, - Integrity: digest, - Dependencies: manifest.Dependencies, - Files: files, - } - lf.SetPackage(dep.PackageID, info) - - // Recursively install this dep's dependencies - if len(manifest.Dependencies) > 0 { - if err := m.installDependencies(manifest.Dependencies, lf, dep.PackageID, installing); err != nil { - return err - } - } - - fmt.Printf(" ✓ Dependency %s@%s installed\n", dep.PackageID, manifest.Version) - installed = true - break + // Try MCP type first (most agent dependencies are MCPs), then assistant. + // Delegate to the appropriate manager so MCP script extraction is handled. + if err := m.mcpMgr.Add(dep.PackageID, mcpmgr.AddOptions{}); err == nil { + fmt.Printf(" ✓ Dependency %s installed (mcp)\n", dep.PackageID) + continue } - if !installed { - return fmt.Errorf("failed to install dependency %s: not found in registry", dep.PackageID) + if err := m.Add(dep.PackageID, AddOptions{}); err == nil { + fmt.Printf(" ✓ Dependency %s installed (assistant)\n", dep.PackageID) + continue } + + return fmt.Errorf("failed to install dependency %s: not found in registry", dep.PackageID) } return nil diff --git a/registry/manager/agent/agent.go b/registry/manager/agent/agent.go index 81be76cd..d28f4143 100644 --- a/registry/manager/agent/agent.go +++ b/registry/manager/agent/agent.go @@ -4,6 +4,7 @@ package agent import ( "github.com/yaoapp/yao/registry" "github.com/yaoapp/yao/registry/manager/common" + mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" ) // Manager handles assistant package operations (add, update, push, fork). @@ -11,6 +12,7 @@ type Manager struct { client *registry.Client appRoot string prompter common.Prompter + mcpMgr *mcpmgr.Manager } // New creates an agent Manager. @@ -22,5 +24,6 @@ func New(client *registry.Client, appRoot string, prompter common.Prompter) *Man client: client, appRoot: appRoot, prompter: prompter, + mcpMgr: mcpmgr.New(client, appRoot, prompter), } } diff --git a/registry/manager/agent_e2e_test.go b/registry/manager/agent_e2e_test.go new file mode 100644 index 00000000..9c199fce --- /dev/null +++ b/registry/manager/agent_e2e_test.go @@ -0,0 +1,464 @@ +package manager_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + agentmgr "github.com/yaoapp/yao/registry/manager/agent" + "github.com/yaoapp/yao/registry/manager/common" + mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" +) + +// ============================================================================= +// Agent with 1 MCP dep: Full lifecycle — Push → Add (auto dep) → Update → Fork +// ============================================================================= + +func TestE2EAgent_SingleDepLifecycle(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "2.0.0") + + // Push MCP dependency first + mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push MCP: %v", err) + } + + // Push agent + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push agent: %v", err) + } + + packument, err := c.GetPackument("assistants", "@"+testScope, "registry-agent") + if err != nil { + t.Fatalf("GetPackument: %v", err) + } + if packument.DistTags["latest"] != "1.0.0" { + t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"]) + } + + // Add to fresh app — MCP should auto-install + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{}); err != nil { + t.Fatalf("Add agent: %v", err) + } + + // Agent on disk + agentDir := filepath.Join(installApp, "assistants", testScope, "registry-agent") + requireFileExists(t, filepath.Join(agentDir, "package.yao")) + requireFileContains(t, filepath.Join(agentDir, "prompts.yml"), "registry E2E testing") + + // Lockfile: agent entry + agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent") + if agentPkg.Version != "1.0.0" { + t.Errorf("want version 1.0.0, got %s", agentPkg.Version) + } + if agentPkg.Type != common.TypeAssistant { + t.Errorf("want type assistant, got %s", agentPkg.Type) + } + if len(agentPkg.Files) == 0 { + t.Error("expected file hashes in lockfile") + } + + // MCP dependency auto-installed + depPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + if depPkg.Version != "1.0.0" { + t.Errorf("dependency version: want 1.0.0, got %s", depPkg.Version) + } + + found := false + for _, rb := range depPkg.RequiredBy { + if rb == "@"+testScope+"/registry-agent" { + found = true + } + } + if !found { + t.Errorf("expected agent in MCP's required_by, got %v", depPkg.RequiredBy) + } + + // Update: push v2, locally modify, then update + v2App := buildV2AgentApp(t) + pushAgentV2 := agentmgr.New(c, v2App, &common.AutoConfirmPrompter{}) + if err := pushAgentV2.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Push agent v2: %v", err) + } + + customPrompt := "My custom prompt - DO NOT OVERWRITE." + os.WriteFile(filepath.Join(agentDir, "prompts.yml"), []byte(customPrompt), 0644) + + if err := installAgent.Update("@"+testScope+"/registry-agent", agentmgr.UpdateOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Update agent: %v", err) + } + + agentPkg = requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent") + if agentPkg.Version != "2.0.0" { + t.Errorf("expected v2.0.0, got %s", agentPkg.Version) + } + + // Local modification preserved + preservedData, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml")) + if string(preservedData) != customPrompt { + t.Errorf("local modification should be preserved, got: %s", preservedData) + } + requireFileExists(t, filepath.Join(agentDir, "prompts.yml.new")) + requireFileContains(t, filepath.Join(agentDir, "prompts.yml.new"), "v2 registry test assistant") + + // New file added by v2 + requireFileExists(t, filepath.Join(agentDir, "tools.ts")) + + // Fork + if err := installAgent.Fork("@"+testScope+"/registry-agent", agentmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork agent: %v", err) + } + + forkDir := filepath.Join(installApp, "assistants", "local", "registry-agent") + requireFileExists(t, filepath.Join(forkDir, "package.yao")) + + forkedPkg := requireLockfileHas(t, installApp, "@local/registry-agent") + if forkedPkg.ForkedFrom != "@"+testScope+"/registry-agent" { + t.Errorf("expected forked_from=@%s/registry-agent, got %s", testScope, forkedPkg.ForkedFrom) + } + if forkedPkg.IsManaged() { + t.Error("forked package should not be managed") + } +} + +// ============================================================================= +// Agent with 2 MCP deps: both auto-installed +// ============================================================================= + +func TestE2EAgent_MultiDepLifecycle(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0") + + // Push both MCP dependencies + mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push registry-mcp: %v", err) + } + if err := mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push data-tools: %v", err) + } + + // Push analytics agent + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := agentMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push analytics: %v", err) + } + + // Install to fresh app + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installAgent.Add("@"+testScope+"/analytics", agentmgr.AddOptions{}); err != nil { + t.Fatalf("Add analytics: %v", err) + } + + // Agent on disk + requireFileExists(t, filepath.Join(installApp, "assistants", testScope, "analytics", "package.yao")) + requireFileContains(t, filepath.Join(installApp, "assistants", testScope, "analytics", "prompts.yml"), + "analytics assistant") + + // Both MCP deps auto-installed + requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + requireLockfileHas(t, installApp, "@"+testScope+"/data-tools") + + // MCP files on disk + requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao")) + requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao")) + + // Scripts from both MCPs + requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts")) + requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts")) + requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts")) + + // required_by on both MCPs should reference analytics + lf, _ := common.LoadLockfile(installApp) + for _, mcpID := range []string{"@" + testScope + "/registry-mcp", "@" + testScope + "/data-tools"} { + mcpPkg, _ := lf.GetPackage(mcpID) + found := false + for _, rb := range mcpPkg.RequiredBy { + if rb == "@"+testScope+"/analytics" { + found = true + } + } + if !found { + t.Errorf("expected analytics in %s's required_by, got %v", mcpID, mcpPkg.RequiredBy) + } + } + + // Analytics lockfile entry + agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/analytics") + if agentPkg.Version != "1.0.0" { + t.Errorf("want version 1.0.0, got %s", agentPkg.Version) + } +} + +// ============================================================================= +// Agent with zero deps: standalone push/add/fork +// ============================================================================= + +func TestE2EAgent_NoDep(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0") + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push simple-greeter: %v", err) + } + + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}); err != nil { + t.Fatalf("Add simple-greeter: %v", err) + } + + requireFileExists(t, filepath.Join(installApp, "assistants", testScope, "simple-greeter", "package.yao")) + requireFileContains(t, filepath.Join(installApp, "assistants", testScope, "simple-greeter", "prompts.yml"), + "friendly greeter") + + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/simple-greeter") + if pkg.Version != "1.0.0" { + t.Errorf("want version 1.0.0, got %s", pkg.Version) + } + if pkg.Type != common.TypeAssistant { + t.Errorf("want type assistant, got %s", pkg.Type) + } + + // No MCP deps should be installed + lf, _ := common.LoadLockfile(installApp) + for id := range lf.Packages { + if id != "@"+testScope+"/simple-greeter" { + t.Errorf("unexpected package in lockfile: %s (standalone agent should have no deps)", id) + } + } + + // Fork + if err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork simple-greeter: %v", err) + } + + requireFileExists(t, filepath.Join(installApp, "assistants", "local", "simple-greeter", "package.yao")) + + forkedPkg := requireLockfileHas(t, installApp, "@local/simple-greeter") + if forkedPkg.ForkedFrom != "@"+testScope+"/simple-greeter" { + t.Errorf("expected forked_from, got %s", forkedPkg.ForkedFrom) + } +} + +// ============================================================================= +// Add already installed — reject without --force +// ============================================================================= + +func TestE2EAgent_AddAlreadyInstalled(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0") + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}) + + err := installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}) + if err == nil { + t.Fatal("expected already-installed error") + } + if !strings.Contains(err.Error(), "already installed") { + t.Errorf("expected 'already installed' error, got: %v", err) + } +} + +// ============================================================================= +// Fork to custom scope +// ============================================================================= + +func TestE2EAgent_ForkToCustomScope(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0") + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}) + + if err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "acme"}); err != nil { + t.Fatalf("Fork to custom scope: %v", err) + } + + requireFileExists(t, filepath.Join(installApp, "assistants", "acme", "simple-greeter", "package.yao")) + + pkg := requireLockfileHas(t, installApp, "@acme/simple-greeter") + if pkg.ForkedFrom != "@"+testScope+"/simple-greeter" { + t.Errorf("expected forked_from, got %s", pkg.ForkedFrom) + } +} + +// ============================================================================= +// Fork target already exists +// ============================================================================= + +func TestE2EAgent_ForkTargetExists(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0") + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}) + + // Pre-create target + targetDir := filepath.Join(installApp, "assistants", "local", "simple-greeter") + mustMkdir(t, targetDir) + + err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"}) + if err == nil { + t.Fatal("expected fork to fail when target exists") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error, got: %v", err) + } +} + +// ============================================================================= +// Push @local is rejected +// ============================================================================= + +func TestE2EAgent_PushLocalRejected(t *testing.T) { + c := authClient() + devApp := t.TempDir() + + localDir := filepath.Join(devApp, "assistants", "local", "my-thing") + mustMkdir(t, localDir) + mustWriteFile(t, filepath.Join(localDir, "package.yao"), `{"name":"my-thing"}`) + + mgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + err := mgr.Push("local.my-thing", agentmgr.PushOptions{Version: "1.0.0"}) + if err == nil { + t.Fatal("expected push of @local to be rejected") + } + if !strings.Contains(err.Error(), "@local") { + t.Errorf("expected @local rejection error, got: %v", err) + } +} + +// ============================================================================= +// Shared MCP dep: two agents share same MCP, verify required_by +// ============================================================================= + +func TestE2EAgent_SharedMCPDep(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0") + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0") + + // Push all dependencies + mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}) + + agentPushMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentPushMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}) + agentPushMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"}) + + // Install agent A (depends on registry-mcp) + installApp := t.TempDir() + installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{}); err != nil { + t.Fatalf("Add registry-agent: %v", err) + } + + // registry-mcp should be installed with required_by=[registry-agent] + mcpPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + if mcpPkg.Version != "1.0.0" { + t.Errorf("want MCP version 1.0.0, got %s", mcpPkg.Version) + } + + // Install agent B (depends on registry-mcp AND data-tools) + if err := installAgent.Add("@"+testScope+"/analytics", agentmgr.AddOptions{}); err != nil { + t.Fatalf("Add analytics: %v", err) + } + + // registry-mcp should NOT be reinstalled, but required_by should include both agents + lf, _ := common.LoadLockfile(installApp) + mcpPkg2, _ := lf.GetPackage("@" + testScope + "/registry-mcp") + + requiredBySet := map[string]bool{} + for _, rb := range mcpPkg2.RequiredBy { + requiredBySet[rb] = true + } + if !requiredBySet["@"+testScope+"/registry-agent"] { + t.Error("expected registry-agent in registry-mcp's required_by") + } + if !requiredBySet["@"+testScope+"/analytics"] { + t.Error("expected analytics in registry-mcp's required_by") + } + + // data-tools should also be installed + requireLockfileHas(t, installApp, "@"+testScope+"/data-tools") + + // Verify disk files are not duplicated — only one copy of each MCP + requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao")) + requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao")) +} + +// ============================================================================= +// Agent Fork from registry: not installed locally, pull then fork +// ============================================================================= + +func TestE2EAgent_ForkFromRegistry(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0") + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}) + + // Fresh app — nothing installed + forkApp := t.TempDir() + forkAgent := agentmgr.New(c, forkApp, &common.AutoConfirmPrompter{}) + + if err := forkAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork from registry: %v", err) + } + + requireFileExists(t, filepath.Join(forkApp, "assistants", "local", "simple-greeter", "package.yao")) + requireFileContains(t, filepath.Join(forkApp, "assistants", "local", "simple-greeter", "prompts.yml"), + "friendly greeter") + + pkg := requireLockfileHas(t, forkApp, "@local/simple-greeter") + if pkg.ForkedFrom != "@"+testScope+"/simple-greeter" { + t.Errorf("expected forked_from, got %s", pkg.ForkedFrom) + } +} diff --git a/registry/manager/e2e_helpers_test.go b/registry/manager/e2e_helpers_test.go new file mode 100644 index 00000000..a09c61b4 --- /dev/null +++ b/registry/manager/e2e_helpers_test.go @@ -0,0 +1,361 @@ +package manager_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yaoapp/yao/registry" + "github.com/yaoapp/yao/registry/manager/common" +) + +// testScope is the scope aligned with registry CI credentials (yaoagents:yaoagents). +const testScope = "yaoagents" + +func registryURL() string { + if u := os.Getenv("YAO_REGISTRY_URL"); u != "" { + return u + } + return "http://localhost:8080" +} + +func authClient() *registry.Client { + return registry.New(registryURL(), registry.WithAuth(testScope, testScope)) +} + +func cleanupPkg(c *registry.Client, pkgType, scope, name, version string) { + c.DeleteVersion(pkgType, scope, name, version) +} + +// appRoot returns the path to yao-dev-app, which contains the real test fixtures +// under assistants/yaoagents/, mcps/yaoagents/, scripts/yaoagents/. +// +// Resolution order: +// 1. YAO_TEST_APPLICATION env var (set by CI and local env.local.sh) +// 2. ../yao-dev-app (local development layout) +// 3. ../app (CI layout after "Move Dependencies" step) +func appRoot(t *testing.T) string { + t.Helper() + + check := func(root string) bool { + _, err := os.Stat(filepath.Join(root, "assistants", testScope, "registry-agent", "package.yao")) + return err == nil + } + + if root := os.Getenv("YAO_TEST_APPLICATION"); root != "" { + abs, _ := filepath.Abs(root) + if check(abs) { + return abs + } + t.Logf("YAO_TEST_APPLICATION=%s exists but missing registry test fixtures", root) + } + + for _, rel := range []string{ + filepath.Join("..", "..", "..", "yao-dev-app"), + filepath.Join("..", "..", "..", "..", "app"), + filepath.Join("..", "yao-dev-app"), + } { + abs, _ := filepath.Abs(rel) + if check(abs) { + return abs + } + } + + t.Skip("yao-dev-app with registry test fixtures not found; set YAO_TEST_APPLICATION") + return "" +} + +// mustMkdir creates a directory tree, failing the test on error. +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } +} + +// mustWriteFile writes content to a file, creating parent directories as needed. +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +// requireFileExists asserts that a file exists on disk. +func requireFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file to exist: %s", path) + } +} + +// requireFileNotExists asserts that a file does NOT exist on disk. +func requireFileNotExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err == nil { + t.Fatalf("expected file NOT to exist: %s", path) + } +} + +// requireFileContains asserts that a file exists and its content contains substr. +func requireFileContains(t *testing.T, path, substr string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read %s: %v", path, err) + } + if !strings.Contains(string(data), substr) { + t.Errorf("expected %s to contain %q, got:\n%s", filepath.Base(path), substr, data) + } +} + +// requireFileNotContains asserts that a file's content does NOT contain substr. +func requireFileNotContains(t *testing.T, path, substr string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read %s: %v", path, err) + } + if strings.Contains(string(data), substr) { + t.Errorf("expected %s NOT to contain %q, got:\n%s", filepath.Base(path), substr, data) + } +} + +// requireLockfileHas asserts that the lockfile at appRoot has a package with the given ID. +func requireLockfileHas(t *testing.T, appRoot, pkgID string) common.PackageInfo { + t.Helper() + lf, err := common.LoadLockfile(appRoot) + if err != nil { + t.Fatalf("load lockfile: %v", err) + } + pkg, ok := lf.GetPackage(pkgID) + if !ok { + t.Fatalf("expected %s in lockfile, packages: %v", pkgID, lockfileKeys(lf)) + } + return pkg +} + +// requireLockfileNotHas asserts that the lockfile does NOT contain the given package. +func requireLockfileNotHas(t *testing.T, appRoot, pkgID string) { + t.Helper() + lf, err := common.LoadLockfile(appRoot) + if err != nil { + t.Fatalf("load lockfile: %v", err) + } + if _, ok := lf.GetPackage(pkgID); ok { + t.Fatalf("expected %s NOT in lockfile", pkgID) + } +} + +// lockfileKeys returns all package IDs in a lockfile (for debug output). +func lockfileKeys(lf *common.RegistryYao) []string { + var keys []string + for k := range lf.Packages { + keys = append(keys, k) + } + return keys +} + +// buildV2AgentApp creates a v2 variant of the agent+MCP fixtures in a temp directory. +// Content differs from v1 in yao-dev-app to verify update logic. +func buildV2AgentApp(t *testing.T) string { + t.Helper() + root := t.TempDir() + + // Agent v2: updated description, new tools.ts + assistDir := filepath.Join(root, "assistants", testScope, "registry-agent") + mustMkdir(t, assistDir) + mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{ + "name": "Registry Test Agent v2", + "avatar": "/api/__yao/app/icons/app.png", + "connector": "gpt-4o", + "description": "Enhanced v2 test assistant for registry E2E verification", + "options": { "temperature": 0.5 }, + "public": false, + "mcp": { + "servers": { + "registry-mcp": { "server_id": "`+testScope+`.registry-mcp" } + } + }, + "tags": ["Test", "Registry", "V2"], + "sort": 999, + "readonly": true, + "automated": false, + "mentionable": false +}`) + mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"), + "system: |\n You are the v2 registry test assistant with enhanced capabilities.\n") + mustWriteFile(t, filepath.Join(assistDir, "tools.ts"), + `export function newV2Tool(): number { return 42; }`) + + // MCP v2: added "suggest" tool + mcpDir := filepath.Join(root, "mcps", testScope, "registry-mcp") + mustMkdir(t, mcpDir) + mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{ + "label": "Registry Test MCP v2", + "description": "Enhanced v2 MCP for registry E2E testing", + "transport": "process", + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, + "tools": { + "ping": "scripts.`+testScope+`.registry_mcp.Ping", + "echo": "scripts.`+testScope+`.registry_mcp.Echo", + "suggest": "scripts.`+testScope+`.registry_mcp.Suggest" + } +}`) + + scriptDir := filepath.Join(root, "scripts", testScope) + mustMkdir(t, scriptDir) + mustWriteFile(t, filepath.Join(scriptDir, "registry_mcp.ts"), `/** + * Registry MCP test script v2 + */ + +function Ping(): string { + return "pong-v2"; +} + +function Echo(input: string): string { + return input; +} + +function Suggest(prefix: string): string[] { + return ["v2-suggestion1", "v2-suggestion2"]; +} +`) + return root +} + +// buildV2MCPApp creates a v2 variant of the data-tools MCP for update testing. +func buildV2MCPApp(t *testing.T) string { + t.Helper() + root := t.TempDir() + + mcpDir := filepath.Join(root, "mcps", testScope, "data-tools") + mustMkdir(t, mcpDir) + mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{ + "label": "Data Tools MCP v2", + "description": "Enhanced v2 data tools with new merge capability", + "transport": "process", + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, + "tools": { + "aggregate": "scripts.`+testScope+`.data_tools.Aggregate", + "transform": "scripts.`+testScope+`.data_tools.Transform", + "validate": "scripts.`+testScope+`.data_tools.Validate", + "merge": "scripts.`+testScope+`.data_tools.Merge", + "format_csv": "scripts.`+testScope+`.data_utils.FormatCSV", + "format_json": "scripts.`+testScope+`.data_utils.FormatJSON" + } +}`) + + scriptDir := filepath.Join(root, "scripts", testScope) + mustMkdir(t, scriptDir) + mustWriteFile(t, filepath.Join(scriptDir, "data_tools.ts"), `/** + * Data Tools MCP v2 - primary script + */ + +function Aggregate(data: any[]): Record { + return { count: data.length, version: 2 }; +} + +function Transform(input: string): string { + return input.toUpperCase() + "-v2"; +} + +function Validate(schema: string, data: any): boolean { + return schema !== "" && data !== null; +} + +function Merge(a: any, b: any): any { + return { ...a, ...b }; +} +`) + mustWriteFile(t, filepath.Join(scriptDir, "data_utils.ts"), `/** + * Data Tools MCP v2 - utility script + */ + +function FormatCSV(rows: string[][]): string { + return "header\\n" + rows.map((r) => r.join(",")).join("\\n"); +} + +function FormatJSON(data: any): string { + return JSON.stringify(data, null, 2); +} +`) + return root +} + +// buildV2AnalyticsApp creates a v2 variant of the analytics agent for update testing. +func buildV2AnalyticsApp(t *testing.T) string { + t.Helper() + root := t.TempDir() + + assistDir := filepath.Join(root, "assistants", testScope, "analytics") + mustMkdir(t, assistDir) + mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{ + "name": "Analytics Agent v2", + "avatar": "/api/__yao/app/icons/app.png", + "connector": "gpt-4o", + "description": "Enhanced v2 analytics assistant with charting support", + "options": { "temperature": 0.2 }, + "public": false, + "mcp": { + "servers": { + "registry-mcp": { "server_id": "`+testScope+`.registry-mcp" }, + "data-tools": { "server_id": "`+testScope+`.data-tools" } + } + }, + "tags": ["Test", "Registry", "Analytics", "V2"], + "sort": 998, + "readonly": true, + "automated": false, + "mentionable": false +}`) + mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"), + "system: |\n You are the v2 analytics assistant with charting capabilities.\n") + mustWriteFile(t, filepath.Join(assistDir, "chart_helper.ts"), + `export function renderChart(): string { return "chart-v2"; }`) + + return root +} + +// buildRobotZip creates a robot package zip and pushes it to the registry. +func buildAndPushRobotZip(t *testing.T, c *registry.Client, name string, robot interface{}, version string) { + t.Helper() + + robotBytes, _ := json.Marshal(robot) + + robotZipRoot := t.TempDir() + robotDir := filepath.Join(robotZipRoot, "package") + mustMkdir(t, robotDir) + mustWriteFile(t, filepath.Join(robotDir, "pkg.yao"), `{ + "type": "robot", + "scope": "@`+testScope+`", + "name": "`+name+`", + "version": "`+version+`", + "description": "E2E test robot" +}`) + mustWriteFile(t, filepath.Join(robotDir, "robot.json"), string(robotBytes)) + + robotZip, err := common.PackDir(robotDir, &common.PkgManifest{ + Type: common.TypeRobot, + Scope: "@" + testScope, + Name: name, + Version: version, + }, nil) + if err != nil { + t.Fatalf("pack robot %s: %v", name, err) + } + if _, err := c.Push("robots", "@"+testScope, name, version, robotZip); err != nil { + t.Fatalf("push robot %s: %v", name, err) + } +} diff --git a/registry/manager/e2e_test.go b/registry/manager/e2e_test.go deleted file mode 100644 index 3b0e401a..00000000 --- a/registry/manager/e2e_test.go +++ /dev/null @@ -1,770 +0,0 @@ -package manager_test - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/yaoapp/yao/registry" - agentmgr "github.com/yaoapp/yao/registry/manager/agent" - "github.com/yaoapp/yao/registry/manager/common" - mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" - robotmgr "github.com/yaoapp/yao/registry/manager/robot" -) - -// testScope is the scope aligned with registry CI credentials (yaoagents:yaoagents). -const testScope = "yaoagents" - -func registryURL() string { - if u := os.Getenv("YAO_REGISTRY_URL"); u != "" { - return u - } - return "http://localhost:8080" -} - -func authClient() *registry.Client { - return registry.New(registryURL(), registry.WithAuth(testScope, testScope)) -} - -func cleanupPkg(c *registry.Client, pkgType, scope, name, version string) { - c.DeleteVersion(pkgType, scope, name, version) -} - -// appRoot returns the path to yao-dev-app, which contains the real test fixtures -// under assistants/yaoagents/, mcps/yaoagents/, scripts/yaoagents/. -// -// Resolution order: -// 1. YAO_TEST_APPLICATION env var (set by CI and local env.local.sh) -// 2. ../yao-dev-app (local development layout) -// 3. ../app (CI layout after "Move Dependencies" step) -func appRoot(t *testing.T) string { - t.Helper() - - check := func(root string) bool { - _, err := os.Stat(filepath.Join(root, "assistants", testScope, "registry-agent", "package.yao")) - return err == nil - } - - if root := os.Getenv("YAO_TEST_APPLICATION"); root != "" { - abs, _ := filepath.Abs(root) - if check(abs) { - return abs - } - t.Logf("YAO_TEST_APPLICATION=%s exists but missing registry test fixtures", root) - } - - // Try standard sibling layouts - for _, rel := range []string{ - filepath.Join("..", "..", "..", "yao-dev-app"), // from registry/manager/ → yao-dev-app - filepath.Join("..", "..", "..", "..", "app"), // CI: from yao/registry/manager/ → ../app - filepath.Join("..", "yao-dev-app"), // from yao/ → yao-dev-app - } { - abs, _ := filepath.Abs(rel) - if check(abs) { - return abs - } - } - - t.Skip("yao-dev-app with registry test fixtures not found; set YAO_TEST_APPLICATION") - return "" -} - -// buildV2App creates a v2 variant of the test fixtures in a temp directory -// for update testing. The content is intentionally different from v1 in yao-dev-app. -func buildV2App(t *testing.T) string { - t.Helper() - root := t.TempDir() - - // Agent v2: updated description, new prompts, added tools.ts - assistDir := filepath.Join(root, "assistants", testScope, "registry-agent") - mustMkdir(t, assistDir) - mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{ - "name": "Registry Test Agent v2", - "avatar": "/api/__yao/app/icons/app.png", - "connector": "gpt-4o", - "description": "Enhanced v2 test assistant for registry E2E verification", - "options": { "temperature": 0.5 }, - "public": false, - "mcp": { - "servers": { - "registry-mcp": { "server_id": "`+testScope+`.registry-mcp" } - } - }, - "tags": ["Test", "Registry", "V2"], - "sort": 999, - "readonly": true, - "automated": false, - "mentionable": false -}`) - mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"), - "system: |\n You are the v2 registry test assistant with enhanced capabilities.\n") - mustWriteFile(t, filepath.Join(assistDir, "tools.ts"), - `export function newV2Tool(): number { return 42; }`) - - // MCP v2: added "suggest" tool - mcpDir := filepath.Join(root, "mcps", testScope, "registry-mcp") - mustMkdir(t, mcpDir) - mustWriteFile(t, filepath.Join(mcpDir, "registry-mcp.mcp.yao"), `{ - "label": "Registry Test MCP v2", - "description": "Enhanced v2 MCP for registry E2E testing", - "transport": "process", - "capabilities": { - "tools": { "listChanged": false }, - "resources": { "subscribe": false, "listChanged": false } - }, - "tools": { - "ping": "scripts.`+testScope+`.registry_mcp.Ping", - "echo": "scripts.`+testScope+`.registry_mcp.Echo", - "suggest": "scripts.`+testScope+`.registry_mcp.Suggest" - } -}`) - - // Script v2: added Suggest, changed Ping return - scriptDir := filepath.Join(root, "scripts", testScope) - mustMkdir(t, scriptDir) - mustWriteFile(t, filepath.Join(scriptDir, "registry_mcp.ts"), `/** - * Registry MCP test script v2 - */ - -function Ping(): string { - return "pong-v2"; -} - -function Echo(input: string): string { - return input; -} - -function Suggest(prefix: string): string[] { - return ["v2-suggestion1", "v2-suggestion2"]; -} -`) - - return root -} - -func mustMkdir(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(path, 0755); err != nil { - t.Fatal(err) - } -} - -func mustWriteFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } -} - -// ============================================================================= -// E2E: MCP full lifecycle — Push from yao-dev-app → Add → Update → Fork -// ============================================================================= - -func TestE2EMCPRealLifecycle(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "2.0.0") - - // ---- Phase 1: Push MCP v1 from yao-dev-app (real developer push) ---- - - pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - - err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - if err != nil { - t.Fatalf("Push MCP v1 from yao-dev-app: %v", err) - } - - // Verify in registry - packument, err := c.GetPackument("mcps", "@"+testScope, "registry-mcp") - if err != nil { - t.Fatalf("GetPackument after push: %v", err) - } - if packument.DistTags["latest"] != "1.0.0" { - t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"]) - } - - // ---- Phase 2: Add to a fresh app (simulates another developer installing) ---- - - installApp := t.TempDir() - installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) - - err = installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) - if err != nil { - t.Fatalf("Add MCP: %v", err) - } - - // Verify: .mcp.yao on disk - installedMCP := filepath.Join(installApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao") - if _, err := os.Stat(installedMCP); err != nil { - t.Fatal("expected registry-mcp.mcp.yao in installed dir") - } - mcpContent, _ := os.ReadFile(installedMCP) - if !strings.Contains(string(mcpContent), "scripts."+testScope+".registry_mcp.Ping") { - t.Errorf("expected process refs preserved, got: %s", mcpContent) - } - - // Verify: scripts extracted to project root - installedScript := filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts") - if _, err := os.Stat(installedScript); err != nil { - t.Fatalf("expected scripts/%s/registry_mcp.ts extracted", testScope) - } - scriptContent, _ := os.ReadFile(installedScript) - if !strings.Contains(string(scriptContent), "pong") { - t.Errorf("expected v1 script with 'pong', got: %s", scriptContent) - } - - // Verify: lockfile (registry.yao) - lf, err := common.LoadLockfile(installApp) - if err != nil { - t.Fatal(err) - } - pkg, ok := lf.GetPackage("@" + testScope + "/registry-mcp") - if !ok { - t.Fatal("expected package in lockfile") - } - if pkg.Version != "1.0.0" { - t.Errorf("lockfile version: want 1.0.0, got %s", pkg.Version) - } - if pkg.Type != common.TypeMCP { - t.Errorf("lockfile type: want mcp, got %s", pkg.Type) - } - if pkg.Integrity == "" { - t.Error("expected integrity digest in lockfile") - } - - // lockfile.Files must track both MCP dir files and script files - hasScript, hasMCP := false, false - for path := range pkg.Files { - if strings.HasPrefix(path, "scripts/") { - hasScript = true - } - if strings.HasPrefix(path, "mcps/") { - hasMCP = true - } - } - if !hasScript { - t.Error("lockfile missing script file entries") - } - if !hasMCP { - t.Error("lockfile missing MCP file entries") - } - - // ---- Phase 3: Push v2 from temp dir, then Update ---- - - v2App := buildV2App(t) - pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{}) - - err = pushMgrV2.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "2.0.0"}) - if err != nil { - t.Fatalf("Push MCP v2: %v", err) - } - - err = installMgr.Update("@"+testScope+"/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"}) - if err != nil { - t.Fatalf("Update MCP to v2: %v", err) - } - - // lockfile version should be 2.0.0 - lf, _ = common.LoadLockfile(installApp) - pkg, _ = lf.GetPackage("@" + testScope + "/registry-mcp") - if pkg.Version != "2.0.0" { - t.Errorf("expected v2.0.0 after update, got %s", pkg.Version) - } - - // Script should contain v2 content - scriptContent, _ = os.ReadFile(installedScript) - if !strings.Contains(string(scriptContent), "pong-v2") { - t.Errorf("expected v2 script content after update, got: %s", scriptContent) - } - - // ---- Phase 4: Fork to @local ---- - - err = installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}) - if err != nil { - t.Fatalf("Fork MCP: %v", err) - } - - // Forked MCP directory - forkedDir := filepath.Join(installApp, "mcps", "local", "registry-mcp") - if _, err := os.Stat(forkedDir); err != nil { - t.Fatal("expected forked MCP directory at mcps/local/registry-mcp/") - } - - // Process refs rewritten: scripts.yaoagents.* → scripts.local.* - forkedMCPContent, _ := os.ReadFile(filepath.Join(forkedDir, "registry-mcp.mcp.yao")) - if !strings.Contains(string(forkedMCPContent), "scripts.local.registry_mcp.Ping") { - t.Errorf("expected rewritten process ref scripts.local.*, got: %s", forkedMCPContent) - } - if strings.Contains(string(forkedMCPContent), "scripts."+testScope+".") { - t.Errorf("forked MCP still references original scope: %s", forkedMCPContent) - } - - // Forked scripts copied - forkedScript := filepath.Join(installApp, "scripts", "local", "registry_mcp.ts") - if _, err := os.Stat(forkedScript); err != nil { - t.Fatal("expected scripts/local/registry_mcp.ts after fork") - } - - // Lockfile: forked entry is unmanaged - lf, _ = common.LoadLockfile(installApp) - forkedPkg, ok := lf.GetPackage("@local/registry-mcp") - if !ok { - t.Fatal("expected @local/registry-mcp in lockfile") - } - if forkedPkg.ForkedFrom != "@"+testScope+"/registry-mcp" { - t.Errorf("expected forked_from=@%s/registry-mcp, got %s", testScope, forkedPkg.ForkedFrom) - } - if forkedPkg.IsManaged() { - t.Error("forked package should not be managed") - } -} - -// ============================================================================= -// E2E: Agent full lifecycle — Push → Add (auto-installs MCP dep) → Update → Fork -// ============================================================================= - -func TestE2EAgentRealLifecycle(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0") - defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "2.0.0") - - // ---- Phase 1: Push MCP dependency first (agent's package.yao references it) ---- - - mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - if err != nil { - t.Fatalf("Push MCP dependency: %v", err) - } - - // ---- Phase 2: Push assistant from yao-dev-app ---- - - agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - err = agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}) - if err != nil { - t.Fatalf("Push agent: %v", err) - } - - packument, err := c.GetPackument("assistants", "@"+testScope, "registry-agent") - if err != nil { - t.Fatalf("GetPackument agent: %v", err) - } - if packument.DistTags["latest"] != "1.0.0" { - t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"]) - } - - // ---- Phase 3: Add agent to fresh app (MCP dependency should auto-install) ---- - - installApp := t.TempDir() - installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{}) - - err = installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{}) - if err != nil { - t.Fatalf("Add agent: %v", err) - } - - // Agent directory on disk - agentDir := filepath.Join(installApp, "assistants", testScope, "registry-agent") - if _, err := os.Stat(agentDir); err != nil { - t.Fatal("expected assistants/" + testScope + "/registry-agent/ directory") - } - if _, err := os.Stat(filepath.Join(agentDir, "package.yao")); err != nil { - t.Error("expected package.yao in installed agent") - } - promptsContent, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml")) - if !strings.Contains(string(promptsContent), "registry E2E testing") { - t.Errorf("expected original prompts content, got: %s", promptsContent) - } - - // Lockfile: agent entry - lf, err := common.LoadLockfile(installApp) - if err != nil { - t.Fatal(err) - } - agentPkg, ok := lf.GetPackage("@" + testScope + "/registry-agent") - if !ok { - t.Fatal("expected agent in lockfile") - } - if agentPkg.Version != "1.0.0" { - t.Errorf("want version 1.0.0, got %s", agentPkg.Version) - } - if agentPkg.Type != common.TypeAssistant { - t.Errorf("want type assistant, got %s", agentPkg.Type) - } - if len(agentPkg.Files) == 0 { - t.Error("expected file hashes in lockfile") - } - - // MCP dependency auto-installed - depPkg, ok := lf.GetPackage("@" + testScope + "/registry-mcp") - if !ok { - t.Fatal("expected MCP dependency @" + testScope + "/registry-mcp auto-installed") - } - if depPkg.Version != "1.0.0" { - t.Errorf("dependency version: want 1.0.0, got %s", depPkg.Version) - } - - // required_by set correctly - found := false - for _, rb := range depPkg.RequiredBy { - if rb == "@"+testScope+"/registry-agent" { - found = true - } - } - if !found { - t.Errorf("expected @%s/registry-agent in dependency's required_by, got %v", testScope, depPkg.RequiredBy) - } - - // ---- Phase 4: Push agent v2 and Update (local modification preserved) ---- - - v2App := buildV2App(t) - pushAgentV2 := agentmgr.New(c, v2App, &common.AutoConfirmPrompter{}) - - err = pushAgentV2.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "2.0.0"}) - if err != nil { - t.Fatalf("Push agent v2: %v", err) - } - - // Locally modify prompts.yml before update (simulates developer customization) - customPrompt := "My custom prompt - DO NOT OVERWRITE." - os.WriteFile(filepath.Join(agentDir, "prompts.yml"), []byte(customPrompt), 0644) - - err = installAgent.Update("@"+testScope+"/registry-agent", agentmgr.UpdateOptions{Version: "2.0.0"}) - if err != nil { - t.Fatalf("Update agent to v2: %v", err) - } - - // Lockfile updated to v2 - lf, _ = common.LoadLockfile(installApp) - agentPkg, _ = lf.GetPackage("@" + testScope + "/registry-agent") - if agentPkg.Version != "2.0.0" { - t.Errorf("expected v2.0.0 after update, got %s", agentPkg.Version) - } - - // Locally modified file PRESERVED (not overwritten) - preservedData, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml")) - if string(preservedData) != customPrompt { - t.Errorf("local modification should be preserved, got: %s", preservedData) - } - - // .new file created with upstream v2 content - newFile := filepath.Join(agentDir, "prompts.yml.new") - if _, err := os.Stat(newFile); err != nil { - t.Error("expected prompts.yml.new with upstream content") - } - newContent, _ := os.ReadFile(newFile) - if !strings.Contains(string(newContent), "v2 registry test assistant") { - t.Errorf("expected v2 content in .new file, got: %s", newContent) - } - - // New file tools.ts added by v2 - if _, err := os.Stat(filepath.Join(agentDir, "tools.ts")); err != nil { - t.Error("expected new file tools.ts added during update") - } - - // ---- Phase 5: Fork to @local ---- - - err = installAgent.Fork("@"+testScope+"/registry-agent", agentmgr.ForkOptions{TargetScope: "local"}) - if err != nil { - t.Fatalf("Fork agent: %v", err) - } - - forkDir := filepath.Join(installApp, "assistants", "local", "registry-agent") - if _, err := os.Stat(forkDir); err != nil { - t.Fatal("expected assistants/local/registry-agent/ after fork") - } - if _, err := os.Stat(filepath.Join(forkDir, "package.yao")); err != nil { - t.Error("expected package.yao in forked dir") - } - - lf, _ = common.LoadLockfile(installApp) - forkedPkg, ok := lf.GetPackage("@local/registry-agent") - if !ok { - t.Fatal("expected @local/registry-agent in lockfile") - } - if forkedPkg.ForkedFrom != "@"+testScope+"/registry-agent" { - t.Errorf("expected forked_from=@%s/registry-agent, got %s", testScope, forkedPkg.ForkedFrom) - } - if forkedPkg.IsManaged() { - t.Error("forked package should not be managed") - } -} - -// ============================================================================= -// E2E: Push→Pull roundtrip (byte-for-byte content verification) -// ============================================================================= - -func TestE2EPushPullRoundtrip(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - - pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - if err != nil { - t.Fatalf("Push: %v", err) - } - - pullApp := t.TempDir() - pullMgr := mcpmgr.New(c, pullApp, &common.AutoConfirmPrompter{}) - err = pullMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) - if err != nil { - t.Fatalf("Add: %v", err) - } - - // Script byte-for-byte comparison - origScript, _ := os.ReadFile(filepath.Join(devApp, "scripts", testScope, "registry_mcp.ts")) - pulledScript, _ := os.ReadFile(filepath.Join(pullApp, "scripts", testScope, "registry_mcp.ts")) - if string(origScript) != string(pulledScript) { - t.Errorf("script mismatch.\nOriginal:\n%s\nPulled:\n%s", origScript, pulledScript) - } - - // MCP definition byte-for-byte comparison - origMCP, _ := os.ReadFile(filepath.Join(devApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao")) - pulledMCP, _ := os.ReadFile(filepath.Join(pullApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao")) - if string(origMCP) != string(pulledMCP) { - t.Errorf("MCP mismatch.\nOriginal:\n%s\nPulled:\n%s", origMCP, pulledMCP) - } -} - -// ============================================================================= -// E2E: Robot Add with dependency resolution -// ============================================================================= - -func TestE2ERobotRealLifecycle(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0") - defer cleanupPkg(c, "robots", "@"+testScope, "test-bot", "1.0.0") - - // Push MCP and Agent that the robot depends on - mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - - if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { - t.Fatalf("Push MCP: %v", err) - } - if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { - t.Fatalf("Push agent: %v", err) - } - - // Build and push robot package (robots are DB records, so we build zip manually) - robotJSON := map[string]interface{}{ - "display_name": "E2E Test Bot", - "system_prompt": "You are an E2E test robot.", - "language_model": "gpt-4o", - "robot_config": map[string]interface{}{ - "resources": map[string]interface{}{ - "phases": map[string]string{ - "host": testScope + ".registry-agent", - }, - }, - }, - "mcp_servers": []string{testScope + ".registry-mcp"}, - } - robotBytes, _ := json.Marshal(robotJSON) - - robotZipRoot := t.TempDir() - robotDir := filepath.Join(robotZipRoot, "package") - mustMkdir(t, robotDir) - mustWriteFile(t, filepath.Join(robotDir, "pkg.yao"), `{ - "type": "robot", - "scope": "@`+testScope+`", - "name": "test-bot", - "version": "1.0.0", - "description": "E2E test robot" -}`) - mustWriteFile(t, filepath.Join(robotDir, "robot.json"), string(robotBytes)) - - robotZip, err := common.PackDir(robotDir, &common.PkgManifest{ - Type: common.TypeRobot, - Scope: "@" + testScope, - Name: "test-bot", - Version: "1.0.0", - }, nil) - if err != nil { - t.Fatalf("Pack robot: %v", err) - } - if _, err := c.Push("robots", "@"+testScope, "test-bot", "1.0.0", robotZip); err != nil { - t.Fatalf("Push robot: %v", err) - } - - // ---- Add robot to fresh app (dependencies should auto-install) ---- - - installApp := t.TempDir() - rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{}) - - robot, err := rMgr.Add("@"+testScope+"/test-bot", robotmgr.AddOptions{TeamID: "team-e2e"}) - if err != nil { - t.Fatalf("Add robot: %v", err) - } - - if robot.DisplayName != "E2E Test Bot" { - t.Errorf("want display_name 'E2E Test Bot', got %q", robot.DisplayName) - } - if robot.SystemPrompt != "You are an E2E test robot." { - t.Errorf("unexpected system_prompt: %s", robot.SystemPrompt) - } - - lf, _ := common.LoadLockfile(installApp) - - robotPkg, ok := lf.GetPackage("@" + testScope + "/test-bot") - if !ok { - t.Fatal("expected robot in lockfile") - } - if robotPkg.Type != common.TypeRobot { - t.Errorf("want robot type, got %s", robotPkg.Type) - } - if robotPkg.TeamID != "team-e2e" { - t.Errorf("want team_id team-e2e, got %s", robotPkg.TeamID) - } - - // Dependencies auto-installed - if _, ok := lf.GetPackage("@" + testScope + "/registry-agent"); !ok { - t.Error("expected agent dependency auto-installed") - } - if _, ok := lf.GetPackage("@" + testScope + "/registry-mcp"); !ok { - t.Error("expected MCP dependency auto-installed") - } - - // Files on disk - if _, err := os.Stat(filepath.Join(installApp, "assistants", testScope, "registry-agent", "package.yao")); err != nil { - t.Error("expected agent package.yao on disk after robot add") - } - if _, err := os.Stat(filepath.Join(installApp, "mcps", testScope, "registry-mcp", "registry-mcp.mcp.yao")); err != nil { - t.Error("expected MCP .mcp.yao on disk after robot add") - } - - // required_by - agentPkg, _ := lf.GetPackage("@" + testScope + "/registry-agent") - foundRB := false - for _, rb := range agentPkg.RequiredBy { - if rb == "@"+testScope+"/test-bot" { - foundRB = true - } - } - if !foundRB { - t.Errorf("expected robot in agent's required_by, got %v", agentPkg.RequiredBy) - } -} - -// ============================================================================= -// E2E: @local push is rejected -// ============================================================================= - -func TestE2EPushLocalRejected(t *testing.T) { - c := authClient() - devApp := t.TempDir() - - localDir := filepath.Join(devApp, "assistants", "local", "my-thing") - mustMkdir(t, localDir) - mustWriteFile(t, filepath.Join(localDir, "package.yao"), `{"name":"my-thing"}`) - - mgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - err := mgr.Push("local.my-thing", agentmgr.PushOptions{Version: "1.0.0"}) - if err == nil { - t.Fatal("expected push of @local to be rejected") - } - if !strings.Contains(err.Error(), "@local") { - t.Errorf("expected @local rejection error, got: %v", err) - } -} - -// ============================================================================= -// E2E: MCP Push rejects scripts in wrong scope -// ============================================================================= - -func TestE2EMCPPushWrongScriptScope(t *testing.T) { - c := authClient() - devApp := t.TempDir() - - mcpDir := filepath.Join(devApp, "mcps", testScope, "bad-mcp") - mustMkdir(t, mcpDir) - mustWriteFile(t, filepath.Join(mcpDir, "bad.mcp.yao"), `{ - "transport": "process", - "tools": { - "run": "scripts.other.bad.Run" - } -}`) - mustWriteFile(t, filepath.Join(devApp, "scripts", "other", "bad.ts"), "export function Run() {}") - - mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - err := mgr.Push(testScope+".bad-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - if err == nil { - t.Fatal("expected push to be rejected due to script scope mismatch") - } - if !strings.Contains(err.Error(), "scope mismatch") { - t.Errorf("expected scope mismatch error, got: %v", err) - } -} - -// ============================================================================= -// E2E: Update of forked package is rejected -// ============================================================================= - -func TestE2EUpdateForkedRejected(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - - pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - - installApp := t.TempDir() - installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) - installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) - - // Fork it - installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}) - - // Update forked should fail - err := installMgr.Update("@local/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"}) - if err == nil { - t.Fatal("expected update of forked package to be rejected") - } - if !strings.Contains(err.Error(), "forked") { - t.Errorf("expected 'forked' in error, got: %v", err) - } -} - -// ============================================================================= -// E2E: Directory conflict detection -// ============================================================================= - -func TestE2EDirectoryConflict(t *testing.T) { - c := authClient() - devApp := appRoot(t) - - defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") - - pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) - pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) - - // Pre-create an unmanaged directory at the install path - installApp := t.TempDir() - conflictDir := filepath.Join(installApp, "mcps", testScope, "registry-mcp") - mustMkdir(t, conflictDir) - mustWriteFile(t, filepath.Join(conflictDir, "my-custom.mcp.yao"), `{"transport":"stdio"}`) - - installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) - err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) - if err == nil { - t.Fatal("expected directory conflict error") - } - if !strings.Contains(err.Error(), "already exists") { - t.Errorf("expected 'already exists' error, got: %v", err) - } -} diff --git a/registry/manager/mcp_e2e_test.go b/registry/manager/mcp_e2e_test.go new file mode 100644 index 00000000..8f960a0d --- /dev/null +++ b/registry/manager/mcp_e2e_test.go @@ -0,0 +1,645 @@ +package manager_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yaoapp/yao/registry/manager/common" + mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" +) + +// ============================================================================= +// Process MCP: Full lifecycle — Push → Add → Update → Fork +// ============================================================================= + +func TestE2EMCP_ProcessLifecycle(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "2.0.0") + + // Phase 1: Push v1 from yao-dev-app + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push MCP v1: %v", err) + } + + packument, err := c.GetPackument("mcps", "@"+testScope, "registry-mcp") + if err != nil { + t.Fatalf("GetPackument: %v", err) + } + if packument.DistTags["latest"] != "1.0.0" { + t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"]) + } + + // Phase 2: Add to a fresh app + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}); err != nil { + t.Fatalf("Add MCP: %v", err) + } + + // Verify .mcp.yao on disk + installedMCP := filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao") + requireFileExists(t, installedMCP) + requireFileContains(t, installedMCP, "scripts."+testScope+".registry_mcp.Ping") + + // Verify scripts extracted to project root + installedScript := filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts") + requireFileExists(t, installedScript) + requireFileContains(t, installedScript, "pong") + + // Verify lockfile + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + if pkg.Version != "1.0.0" { + t.Errorf("lockfile version: want 1.0.0, got %s", pkg.Version) + } + if pkg.Type != common.TypeMCP { + t.Errorf("lockfile type: want mcp, got %s", pkg.Type) + } + if pkg.Integrity == "" { + t.Error("expected integrity digest in lockfile") + } + + hasScript, hasMCP := false, false + for path := range pkg.Files { + if strings.HasPrefix(path, "scripts/") { + hasScript = true + } + if strings.HasPrefix(path, "mcps/") { + hasMCP = true + } + } + if !hasScript { + t.Error("lockfile missing script file entries") + } + if !hasMCP { + t.Error("lockfile missing MCP file entries") + } + + // Phase 3: Push v2, then Update + v2App := buildV2AgentApp(t) + pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{}) + if err := pushMgrV2.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Push MCP v2: %v", err) + } + + if err := installMgr.Update("@"+testScope+"/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Update MCP to v2: %v", err) + } + + pkg = requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + if pkg.Version != "2.0.0" { + t.Errorf("expected v2.0.0 after update, got %s", pkg.Version) + } + requireFileContains(t, installedScript, "pong-v2") + + // Phase 4: Fork to @local + if err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork MCP: %v", err) + } + + forkedDir := filepath.Join(installApp, "mcps", "local", "registry-mcp") + requireFileExists(t, forkedDir) + + forkedMCPPath := filepath.Join(forkedDir, "server.mcp.yao") + requireFileContains(t, forkedMCPPath, "scripts.local.registry_mcp.Ping") + requireFileNotContains(t, forkedMCPPath, "scripts."+testScope+".") + + forkedScript := filepath.Join(installApp, "scripts", "local", "registry_mcp.ts") + requireFileExists(t, forkedScript) + + forkedPkg := requireLockfileHas(t, installApp, "@local/registry-mcp") + if forkedPkg.ForkedFrom != "@"+testScope+"/registry-mcp" { + t.Errorf("expected forked_from=@%s/registry-mcp, got %s", testScope, forkedPkg.ForkedFrom) + } + if forkedPkg.IsManaged() { + t.Error("forked package should not be managed") + } +} + +// ============================================================================= +// SSE MCP: Push → Add → Fork (no scripts, no process refs) +// ============================================================================= + +func TestE2EMCP_SSELifecycle(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "sse-proxy", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := pushMgr.Push(testScope+".sse-proxy", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push SSE MCP: %v", err) + } + + // Add to fresh app + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + if err := installMgr.Add("@"+testScope+"/sse-proxy", mcpmgr.AddOptions{}); err != nil { + t.Fatalf("Add SSE MCP: %v", err) + } + + // Verify .mcp.yao on disk + installedMCP := filepath.Join(installApp, "mcps", testScope, "sse-proxy", "server.mcp.yao") + requireFileExists(t, installedMCP) + requireFileContains(t, installedMCP, `"transport": "sse"`) + requireFileContains(t, installedMCP, "mcp.example.com") + + // No scripts should be extracted for SSE + scriptsDir := filepath.Join(installApp, "scripts") + if _, err := os.Stat(scriptsDir); err == nil { + entries, _ := os.ReadDir(scriptsDir) + if len(entries) > 0 { + t.Errorf("SSE MCP should not extract any scripts, found entries under scripts/") + } + } + + // Lockfile + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/sse-proxy") + if pkg.Version != "1.0.0" { + t.Errorf("want version 1.0.0, got %s", pkg.Version) + } + if pkg.Type != common.TypeMCP { + t.Errorf("want type mcp, got %s", pkg.Type) + } + + // No script files tracked in lockfile + for path := range pkg.Files { + if strings.HasPrefix(path, "scripts/") { + t.Errorf("SSE MCP lockfile should not track scripts, found: %s", path) + } + } + + // Fork to @local — no process ref rewriting needed + if err := installMgr.Fork("@"+testScope+"/sse-proxy", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork SSE MCP: %v", err) + } + + forkedMCP := filepath.Join(installApp, "mcps", "local", "sse-proxy", "server.mcp.yao") + requireFileExists(t, forkedMCP) + requireFileContains(t, forkedMCP, `"transport": "sse"`) + + forkedPkg := requireLockfileHas(t, installApp, "@local/sse-proxy") + if forkedPkg.ForkedFrom != "@"+testScope+"/sse-proxy" { + t.Errorf("expected forked_from=@%s/sse-proxy, got %s", testScope, forkedPkg.ForkedFrom) + } +} + +// ============================================================================= +// Multi-script MCP: Push → Add → verify all scripts unpacked +// ============================================================================= + +func TestE2EMCP_MultiScriptPack(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := pushMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push data-tools: %v", err) + } + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + if err := installMgr.Add("@"+testScope+"/data-tools", mcpmgr.AddOptions{}); err != nil { + t.Fatalf("Add data-tools: %v", err) + } + + // Both script files should be extracted + requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts")) + requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts")) + requireFileContains(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts"), "Aggregate") + requireFileContains(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts"), "FormatCSV") + + // MCP definition on disk + requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao")) + requireFileContains(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao"), + "scripts."+testScope+".data_utils.FormatCSV") + + // Lockfile tracks both script files + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/data-tools") + scriptCount := 0 + for path := range pkg.Files { + if strings.HasPrefix(path, "scripts/") { + scriptCount++ + } + } + if scriptCount < 2 { + t.Errorf("expected at least 2 script files tracked in lockfile, got %d", scriptCount) + } +} + +// ============================================================================= +// Multi-script MCP: Update with local modification +// ============================================================================= + +func TestE2EMCP_MultiScriptUpdate(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0") + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "2.0.0") + + // Push v1 + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := pushMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push v1: %v", err) + } + + // Install v1 + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + if err := installMgr.Add("@"+testScope+"/data-tools", mcpmgr.AddOptions{}); err != nil { + t.Fatalf("Add v1: %v", err) + } + + // Locally modify one of the script files + modifiedScript := filepath.Join(installApp, "scripts", testScope, "data_tools.ts") + customContent := "// MY CUSTOM DATA TOOLS\nfunction Aggregate() { return 'custom'; }\n" + os.WriteFile(modifiedScript, []byte(customContent), 0644) + + // Push v2 + v2App := buildV2MCPApp(t) + pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{}) + if err := pushMgrV2.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Push v2: %v", err) + } + + // Update to v2 + if err := installMgr.Update("@"+testScope+"/data-tools", mcpmgr.UpdateOptions{Version: "2.0.0"}); err != nil { + t.Fatalf("Update to v2: %v", err) + } + + // Modified script should be preserved, .new file created + content, _ := os.ReadFile(modifiedScript) + if !strings.Contains(string(content), "MY CUSTOM DATA TOOLS") { + t.Error("local modification to data_tools.ts should be preserved") + } + requireFileExists(t, modifiedScript+".new") + requireFileContains(t, modifiedScript+".new", "version: 2") + + // Unmodified script should be updated in-place + utilsScript := filepath.Join(installApp, "scripts", testScope, "data_utils.ts") + requireFileContains(t, utilsScript, "header") + + // Lockfile version should be 2.0.0 + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/data-tools") + if pkg.Version != "2.0.0" { + t.Errorf("expected v2.0.0 after update, got %s", pkg.Version) + } +} + +// ============================================================================= +// Push → Pull roundtrip (byte-for-byte content verification) +// ============================================================================= + +func TestE2EMCP_PushPullRoundtrip(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + if err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push: %v", err) + } + + pullApp := t.TempDir() + pullMgr := mcpmgr.New(c, pullApp, &common.AutoConfirmPrompter{}) + if err := pullMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}); err != nil { + t.Fatalf("Add: %v", err) + } + + origScript, _ := os.ReadFile(filepath.Join(devApp, "scripts", testScope, "registry_mcp.ts")) + pulledScript, _ := os.ReadFile(filepath.Join(pullApp, "scripts", testScope, "registry_mcp.ts")) + if string(origScript) != string(pulledScript) { + t.Errorf("script mismatch.\nOriginal:\n%s\nPulled:\n%s", origScript, pulledScript) + } + + origMCP, _ := os.ReadFile(filepath.Join(devApp, "mcps", testScope, "registry-mcp", "server.mcp.yao")) + pulledMCP, _ := os.ReadFile(filepath.Join(pullApp, "mcps", testScope, "registry-mcp", "server.mcp.yao")) + if string(origMCP) != string(pulledMCP) { + t.Errorf("MCP mismatch.\nOriginal:\n%s\nPulled:\n%s", origMCP, pulledMCP) + } +} + +// ============================================================================= +// Push rejects wrong script scope +// ============================================================================= + +func TestE2EMCP_PushWrongScriptScope(t *testing.T) { + c := authClient() + devApp := t.TempDir() + + mcpDir := filepath.Join(devApp, "mcps", testScope, "bad-mcp") + mustMkdir(t, mcpDir) + mustWriteFile(t, filepath.Join(mcpDir, "bad.mcp.yao"), `{ + "transport": "process", + "tools": { + "run": "scripts.other.bad.Run" + } +}`) + mustWriteFile(t, filepath.Join(devApp, "scripts", "other", "bad.ts"), "export function Run() {}") + + mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + err := mgr.Push(testScope+".bad-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + if err == nil { + t.Fatal("expected push to be rejected due to script scope mismatch") + } + if !strings.Contains(err.Error(), "scope mismatch") { + t.Errorf("expected scope mismatch error, got: %v", err) + } +} + +// ============================================================================= +// Push rejects when referenced script file is missing +// ============================================================================= + +func TestE2EMCP_PushMissingScript(t *testing.T) { + c := authClient() + devApp := t.TempDir() + + mcpDir := filepath.Join(devApp, "mcps", testScope, "missing-script-mcp") + mustMkdir(t, mcpDir) + mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{ + "transport": "process", + "tools": { + "run": "scripts.`+testScope+`.nonexistent.Run" + } +}`) + + mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + err := mgr.Push(testScope+".missing-script-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + if err == nil { + t.Fatal("expected push to fail when script file is missing") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +// ============================================================================= +// Push requires --version +// ============================================================================= + +func TestE2EMCP_PushNoVersion(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + err := mgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{}) + if err == nil { + t.Fatal("expected push to fail without --version") + } + if !strings.Contains(err.Error(), "version") { + t.Errorf("expected version error, got: %v", err) + } +} + +// ============================================================================= +// Add: directory conflict (exists but not managed) +// ============================================================================= + +func TestE2EMCP_AddDirectoryConflict(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + conflictDir := filepath.Join(installApp, "mcps", testScope, "registry-mcp") + mustMkdir(t, conflictDir) + mustWriteFile(t, filepath.Join(conflictDir, "my-custom.mcp.yao"), `{"transport":"sse"}`) + + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + if err == nil { + t.Fatal("expected directory conflict error") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error, got: %v", err) + } +} + +// ============================================================================= +// Add: script conflict (scripts/ file exists but not tracked) +// ============================================================================= + +func TestE2EMCP_AddScriptConflict(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + mustWriteFile(t, filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts"), + "// my existing custom script — should block install\n") + + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + if err == nil { + t.Fatal("expected script conflict error") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error about script, got: %v", err) + } +} + +// ============================================================================= +// Add: already installed — reject without --force +// ============================================================================= + +func TestE2EMCP_AddAlreadyInstalled(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + + // Second add should fail + err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + if err == nil { + t.Fatal("expected already-installed error on second add") + } + if !strings.Contains(err.Error(), "already installed") { + t.Errorf("expected 'already installed' error, got: %v", err) + } +} + +// ============================================================================= +// Add: --force reinstall +// ============================================================================= + +func TestE2EMCP_AddForceReinstall(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + + // Force reinstall should succeed + err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{Force: true}) + if err != nil { + t.Fatalf("force reinstall should succeed: %v", err) + } + + pkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + if pkg.Version != "1.0.0" { + t.Errorf("expected 1.0.0 after force reinstall, got %s", pkg.Version) + } +} + +// ============================================================================= +// Update of forked package is rejected +// ============================================================================= + +func TestE2EMCP_UpdateForkedRejected(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}) + + err := installMgr.Update("@local/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"}) + if err == nil { + t.Fatal("expected update of forked package to be rejected") + } + if !strings.Contains(err.Error(), "forked") { + t.Errorf("expected 'forked' in error, got: %v", err) + } +} + +// ============================================================================= +// Fork: target already exists +// ============================================================================= + +func TestE2EMCP_ForkTargetExists(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + + // Pre-create target directory + targetDir := filepath.Join(installApp, "mcps", "local", "registry-mcp") + mustMkdir(t, targetDir) + + err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}) + if err == nil { + t.Fatal("expected fork to fail when target directory exists") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error, got: %v", err) + } +} + +// ============================================================================= +// Fork from registry: package not installed locally, pull then fork +// ============================================================================= + +func TestE2EMCP_ForkFromRegistry(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + // Fresh app — nothing installed + forkApp := t.TempDir() + forkMgr := mcpmgr.New(c, forkApp, &common.AutoConfirmPrompter{}) + + if err := forkMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil { + t.Fatalf("Fork from registry: %v", err) + } + + // Forked MCP on disk + forkedMCP := filepath.Join(forkApp, "mcps", "local", "registry-mcp", "server.mcp.yao") + requireFileExists(t, forkedMCP) + requireFileContains(t, forkedMCP, "scripts.local.registry_mcp.Ping") + + // Forked scripts on disk + requireFileExists(t, filepath.Join(forkApp, "scripts", "local", "registry_mcp.ts")) + + // Lockfile entry + pkg := requireLockfileHas(t, forkApp, "@local/registry-mcp") + if pkg.ForkedFrom != "@"+testScope+"/registry-mcp" { + t.Errorf("expected forked_from, got %s", pkg.ForkedFrom) + } + if pkg.IsManaged() { + t.Error("forked package should not be managed") + } +} + +// ============================================================================= +// Fork to custom scope (not @local) +// ============================================================================= + +func TestE2EMCP_ForkToCustomScope(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + + pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + + installApp := t.TempDir() + installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}) + + if err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "mycompany"}); err != nil { + t.Fatalf("Fork to custom scope: %v", err) + } + + forkedMCP := filepath.Join(installApp, "mcps", "mycompany", "registry-mcp", "server.mcp.yao") + requireFileExists(t, forkedMCP) + requireFileContains(t, forkedMCP, "scripts.mycompany.registry_mcp.Ping") + + forkedScript := filepath.Join(installApp, "scripts", "mycompany", "registry_mcp.ts") + requireFileExists(t, forkedScript) + + pkg := requireLockfileHas(t, installApp, "@mycompany/registry-mcp") + if pkg.ForkedFrom != "@"+testScope+"/registry-mcp" { + t.Errorf("unexpected forked_from: %s", pkg.ForkedFrom) + } +} diff --git a/registry/manager/robot_e2e_test.go b/registry/manager/robot_e2e_test.go new file mode 100644 index 00000000..5884fa3b --- /dev/null +++ b/registry/manager/robot_e2e_test.go @@ -0,0 +1,256 @@ +package manager_test + +import ( + "testing" + + agentmgr "github.com/yaoapp/yao/registry/manager/agent" + "github.com/yaoapp/yao/registry/manager/common" + mcpmgr "github.com/yaoapp/yao/registry/manager/mcp" + robotmgr "github.com/yaoapp/yao/registry/manager/robot" +) + +// ============================================================================= +// Robot Add with agent + MCP dependencies +// ============================================================================= + +func TestE2ERobot_AddWithDeps(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0") + defer cleanupPkg(c, "robots", "@"+testScope, "test-bot", "1.0.0") + + // Push dependencies + mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + + if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push MCP: %v", err) + } + if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil { + t.Fatalf("Push agent: %v", err) + } + + // Build and push robot package + robotJSON := map[string]interface{}{ + "display_name": "E2E Test Bot", + "system_prompt": "You are an E2E test robot.", + "language_model": "gpt-4o", + "robot_config": map[string]interface{}{ + "resources": map[string]interface{}{ + "phases": map[string]string{ + "host": testScope + ".registry-agent", + }, + }, + }, + "mcp_servers": []string{testScope + ".registry-mcp"}, + } + buildAndPushRobotZip(t, c, "test-bot", robotJSON, "1.0.0") + + // Install robot to fresh app + installApp := t.TempDir() + rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + robot, err := rMgr.Add("@"+testScope+"/test-bot", robotmgr.AddOptions{TeamID: "team-e2e"}) + if err != nil { + t.Fatalf("Add robot: %v", err) + } + + if robot.DisplayName != "E2E Test Bot" { + t.Errorf("want display_name 'E2E Test Bot', got %q", robot.DisplayName) + } + if robot.SystemPrompt != "You are an E2E test robot." { + t.Errorf("unexpected system_prompt: %s", robot.SystemPrompt) + } + + // Lockfile: robot entry + robotPkg := requireLockfileHas(t, installApp, "@"+testScope+"/test-bot") + if robotPkg.Type != common.TypeRobot { + t.Errorf("want robot type, got %s", robotPkg.Type) + } + if robotPkg.TeamID != "team-e2e" { + t.Errorf("want team_id team-e2e, got %s", robotPkg.TeamID) + } + + // Dependencies auto-installed + requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent") + requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + + // Files on disk + requireFileExists(t, installApp+"/assistants/"+testScope+"/registry-agent/package.yao") + requireFileExists(t, installApp+"/mcps/"+testScope+"/registry-mcp/server.mcp.yao") + + // required_by on agent from robot + lf, _ := common.LoadLockfile(installApp) + agentPkg, _ := lf.GetPackage("@" + testScope + "/registry-agent") + foundRB := false + for _, rb := range agentPkg.RequiredBy { + if rb == "@"+testScope+"/test-bot" { + foundRB = true + } + } + if !foundRB { + t.Errorf("expected robot in agent's required_by, got %v", agentPkg.RequiredBy) + } +} + +// ============================================================================= +// Robot Add with no dependencies +// ============================================================================= + +func TestE2ERobot_AddNoDeps(t *testing.T) { + c := authClient() + + defer cleanupPkg(c, "robots", "@"+testScope, "simple-bot", "1.0.0") + + robotJSON := map[string]interface{}{ + "display_name": "Simple Bot", + "system_prompt": "You are a simple bot with no dependencies.", + "language_model": "gpt-4o-mini", + } + buildAndPushRobotZip(t, c, "simple-bot", robotJSON, "1.0.0") + + installApp := t.TempDir() + rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + robot, err := rMgr.Add("@"+testScope+"/simple-bot", robotmgr.AddOptions{TeamID: "team-simple"}) + if err != nil { + t.Fatalf("Add robot: %v", err) + } + + if robot.DisplayName != "Simple Bot" { + t.Errorf("want 'Simple Bot', got %q", robot.DisplayName) + } + if robot.LanguageModel != "gpt-4o-mini" { + t.Errorf("want gpt-4o-mini, got %s", robot.LanguageModel) + } + + robotPkg := requireLockfileHas(t, installApp, "@"+testScope+"/simple-bot") + if robotPkg.Type != common.TypeRobot { + t.Errorf("want robot type, got %s", robotPkg.Type) + } + if robotPkg.TeamID != "team-simple" { + t.Errorf("want team_id team-simple, got %s", robotPkg.TeamID) + } + + // No other packages should be installed + lf, _ := common.LoadLockfile(installApp) + for id := range lf.Packages { + if id != "@"+testScope+"/simple-bot" { + t.Errorf("unexpected package %s in lockfile (no-dep robot should be alone)", id) + } + } +} + +// ============================================================================= +// Robot Add: team ID is required +// ============================================================================= + +func TestE2ERobot_AddRequiresTeam(t *testing.T) { + c := authClient() + + defer cleanupPkg(c, "robots", "@"+testScope, "simple-bot", "1.0.0") + + robotJSON := map[string]interface{}{ + "display_name": "Simple Bot", + "system_prompt": "You are a simple bot.", + } + buildAndPushRobotZip(t, c, "simple-bot", robotJSON, "1.0.0") + + installApp := t.TempDir() + rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + _, err := rMgr.Add("@"+testScope+"/simple-bot", robotmgr.AddOptions{}) + if err == nil { + t.Fatal("expected error when team is missing") + } + if err.Error() != "--team is required for robot add" { + t.Errorf("unexpected error: %v", err) + } +} + +// ============================================================================= +// Robot → Agent → MCP dependency chain: required_by propagation +// ============================================================================= + +func TestE2ERobot_RequiredByChain(t *testing.T) { + c := authClient() + devApp := appRoot(t) + + defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0") + defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0") + defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0") + defer cleanupPkg(c, "robots", "@"+testScope, "analytics-bot", "1.0.0") + + // Push full dependency tree: 2 MCPs → analytics agent → robot + mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}) + mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}) + + agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{}) + agentMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"}) + + robotJSON := map[string]interface{}{ + "display_name": "Analytics Bot", + "system_prompt": "You are an analytics bot.", + "language_model": "gpt-4o", + "robot_config": map[string]interface{}{ + "resources": map[string]interface{}{ + "phases": map[string]string{ + "host": testScope + ".analytics", + }, + }, + }, + } + buildAndPushRobotZip(t, c, "analytics-bot", robotJSON, "1.0.0") + + // Install robot to fresh app + installApp := t.TempDir() + rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{}) + + _, err := rMgr.Add("@"+testScope+"/analytics-bot", robotmgr.AddOptions{TeamID: "team-chain"}) + if err != nil { + t.Fatalf("Add robot: %v", err) + } + + // Entire dependency chain should be installed + requireLockfileHas(t, installApp, "@"+testScope+"/analytics-bot") + requireLockfileHas(t, installApp, "@"+testScope+"/analytics") + requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp") + requireLockfileHas(t, installApp, "@"+testScope+"/data-tools") + + // required_by: robot → analytics + lf, _ := common.LoadLockfile(installApp) + analyticsPkg, _ := lf.GetPackage("@" + testScope + "/analytics") + foundRobot := false + for _, rb := range analyticsPkg.RequiredBy { + if rb == "@"+testScope+"/analytics-bot" { + foundRobot = true + } + } + if !foundRobot { + t.Errorf("expected analytics-bot in analytics's required_by, got %v", analyticsPkg.RequiredBy) + } + + // required_by: analytics → MCPs (set by agent Add's dependency installation) + // The MCP's required_by may include analytics (set by agent add) and/or analytics-bot (set by robot add) + for _, mcpID := range []string{"@" + testScope + "/registry-mcp", "@" + testScope + "/data-tools"} { + mcpPkg, ok := lf.GetPackage(mcpID) + if !ok { + t.Errorf("MCP %s not found in lockfile", mcpID) + continue + } + if len(mcpPkg.RequiredBy) == 0 { + t.Errorf("expected required_by on %s, got empty", mcpID) + } + } + + // Verify disk completeness + requireFileExists(t, installApp+"/assistants/"+testScope+"/analytics/package.yao") + requireFileExists(t, installApp+"/mcps/"+testScope+"/registry-mcp/server.mcp.yao") + requireFileExists(t, installApp+"/mcps/"+testScope+"/data-tools/server.mcp.yao") + requireFileExists(t, installApp+"/scripts/"+testScope+"/registry_mcp.ts") + requireFileExists(t, installApp+"/scripts/"+testScope+"/data_tools.ts") + requireFileExists(t, installApp+"/scripts/"+testScope+"/data_utils.ts") +}