yao/registry/manager/e2e_test.go
Max 44af6ba759 Enhance command structure with MCP and Robot functionalities
- Introduce new MCP and Robot command groups in the CLI, allowing for better organization of package management commands.
- Add corresponding command descriptions for MCP and Robot functionalities to improve user guidance.
- Update the agent command group to include additional commands for enhanced agent management.
- Modify .gitignore to exclude specific design markdown files from version control.
- Add environment variable for test application path in GitHub workflows to streamline testing setup.
2026-03-03 08:11:43 +08:00

770 lines
26 KiB
Go

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)
}
}