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.
This commit is contained in:
parent
44af6ba759
commit
634bc1d093
8 changed files with 1749 additions and 825 deletions
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
testScope = "@test"
|
||||
testScope = "@yaoagents"
|
||||
)
|
||||
|
||||
func serverURL() string {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
464
registry/manager/agent_e2e_test.go
Normal file
464
registry/manager/agent_e2e_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
361
registry/manager/e2e_helpers_test.go
Normal file
361
registry/manager/e2e_helpers_test.go
Normal file
|
|
@ -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<string, number> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
645
registry/manager/mcp_e2e_test.go
Normal file
645
registry/manager/mcp_e2e_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
256
registry/manager/robot_e2e_test.go
Normal file
256
registry/manager/robot_e2e_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue