- 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.
770 lines
26 KiB
Go
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)
|
|
}
|
|
}
|