yao/registry/manager/agent/agent_test.go
Max dae35cfe86 Implement force push option for agent and MCP commands
- Add a `--force` flag to the `push` command in both agent and MCP modules, allowing users to overwrite existing versions by deleting them before pushing.
- Update the `PushOptions` struct to include the `Force` field, ensuring the functionality is integrated into the push logic.
- Enhance E2E tests to validate the behavior of the force push feature, ensuring that existing versions are deleted as expected when the flag is used.
- Introduce tests for packing directories to respect `.yaoignore` rules, improving file exclusion handling during the packaging process.
2026-03-03 10:31:50 +08:00

681 lines
18 KiB
Go

package agent
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
"github.com/yaoapp/yao/registry/testdata"
)
// buildTestZip builds a simple assistant .yao.zip for testing.
func buildTestZip(scope, name, version string, deps []testdata.ManifestDep, files map[string]string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "assistant",
Scope: scope,
Name: name,
Version: version,
Dependencies: deps,
}, files)
if err != nil {
panic(err)
}
return zip
}
// buildMCPTestZip builds a simple MCP .yao.zip for testing.
func buildMCPTestZip(scope, name, version string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "mcp",
Scope: scope,
Name: name,
Version: version,
}, map[string]string{
"test.mcp.yao": `{"transport":"process"}`,
})
if err != nil {
panic(err)
}
return zip
}
// mockRegistryServer creates a test HTTP server that serves pre-built zip packages.
func mockRegistryServer(packages map[string][]byte) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// well-known discovery
if r.URL.Path == "/.well-known/yao-registry" {
json.NewEncoder(w).Encode(map[string]interface{}{
"registry": map[string]string{"version": "1.0.0", "api": "/v1"},
"types": []string{"assistants", "mcps", "robots"},
})
return
}
// Pull: GET /v1/{type}/{scope}/{name}/{version}/pull
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
if len(parts) >= 4 {
key := parts[0] + "/" + parts[1] + "/" + parts[2]
if zipData, ok := packages[key]; ok {
w.Header().Set("X-Digest", "sha256-test")
w.Write(zipData)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
// Delete: DELETE /v1/{type}/{scope}/{name}/{version}
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
return
}
// Push: PUT /v1/{type}/{scope}/{name}/{version}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
result := map[string]string{
"type": parts[0],
"scope": parts[1],
"name": parts[2],
"version": parts[3],
"digest": "sha256-pushed",
}
json.NewEncoder(w).Encode(result)
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestAddBasic(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "demo-agent", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"demo"}`,
"prompts.yml": "You are a demo.",
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/demo-agent": zip,
})
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/demo-agent", AddOptions{})
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// Verify directory created
destDir := filepath.Join(appRoot, "assistants", "test", "demo-agent")
if _, err := os.Stat(destDir); err != nil {
t.Fatalf("expected directory %s to exist", destDir)
}
// Verify files
if _, err := os.Stat(filepath.Join(destDir, "package.yao")); err != nil {
t.Error("expected package.yao")
}
if _, err := os.Stat(filepath.Join(destDir, "prompts.yml")); err != nil {
t.Error("expected prompts.yml")
}
// Verify lockfile
lf, err := common.LoadLockfile(appRoot)
if err != nil {
t.Fatal(err)
}
pkg, ok := lf.GetPackage("@test/demo-agent")
if !ok {
t.Fatal("expected @test/demo-agent in lockfile")
}
if pkg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", pkg.Version)
}
if pkg.Type != common.TypeAssistant {
t.Errorf("expected type assistant, got %s", pkg.Type)
}
if len(pkg.Files) == 0 {
t.Error("expected non-empty files hash")
}
}
func TestAddAlreadyInstalled(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "dup", "1.0.0", nil, nil)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/dup": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
if err := mgr.Add("@test/dup", AddOptions{}); err != nil {
t.Fatal(err)
}
// Second add should fail
err := mgr.Add("@test/dup", AddOptions{})
if err == nil {
t.Fatal("expected error for duplicate install")
}
if !strings.Contains(err.Error(), "already installed") {
t.Errorf("expected 'already installed' error, got: %v", err)
}
}
func TestAddDirectoryConflict(t *testing.T) {
appRoot := t.TempDir()
// Create conflicting directory manually (not managed)
os.MkdirAll(filepath.Join(appRoot, "assistants", "test", "conflict"), 0755)
zip := buildTestZip("@test", "conflict", "1.0.0", nil, nil)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/conflict": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/conflict", AddOptions{})
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "not managed by registry") {
t.Errorf("expected conflict error, got: %v", err)
}
}
func TestAddWithDependencies(t *testing.T) {
appRoot := t.TempDir()
mcpZip := buildMCPTestZip("@test", "dep-mcp", "1.0.0")
agentZip := buildTestZip("@test", "dep-agent", "1.0.0",
[]testdata.ManifestDep{
{Type: "mcp", Scope: "@test", Name: "dep-mcp", Version: "^1.0.0"},
},
map[string]string{"package.yao": `{"name":"dep-agent"}`},
)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/dep-agent": agentZip,
"mcps/@test/dep-mcp": mcpZip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/dep-agent", AddOptions{})
if err != nil {
t.Fatalf("Add with deps failed: %v", err)
}
// Verify dependency was installed
lf, _ := common.LoadLockfile(appRoot)
if _, ok := lf.GetPackage("@test/dep-mcp"); !ok {
t.Error("expected dependency @test/dep-mcp to be installed")
}
}
func TestUpdateBasic(t *testing.T) {
appRoot := t.TempDir()
zipV1 := buildTestZip("@test", "updatable", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"updatable"}`,
"prompts.yml": "Original prompt.",
})
zipV2 := buildTestZip("@test", "updatable", "2.0.0", nil, map[string]string{
"package.yao": `{"name":"updatable","version":"2.0.0"}`,
"prompts.yml": "Updated prompt.",
"new-file.md": "New in v2.",
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/updatable": zipV2,
})
defer srv.Close()
// First install v1 using the real zip
srvV1 := mockRegistryServer(map[string][]byte{
"assistants/@test/updatable": zipV1,
})
clientV1 := registry.New(srvV1.URL)
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
if err := mgrV1.Add("@test/updatable", AddOptions{}); err != nil {
t.Fatal(err)
}
srvV1.Close()
// Now update to v2
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@test/updatable", UpdateOptions{})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Verify version updated in lockfile
lf, _ := common.LoadLockfile(appRoot)
pkg, _ := lf.GetPackage("@test/updatable")
if pkg.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
}
// Verify new file exists
newFilePath := filepath.Join(appRoot, "assistants", "test", "updatable", "new-file.md")
if _, err := os.Stat(newFilePath); err != nil {
t.Error("expected new-file.md to be added")
}
}
func TestUpdateLocallyModified(t *testing.T) {
appRoot := t.TempDir()
zipV1 := buildTestZip("@test", "modified", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"modified"}`,
"prompts.yml": "Original.",
})
zipV2 := buildTestZip("@test", "modified", "2.0.0", nil, map[string]string{
"package.yao": `{"name":"modified"}`,
"prompts.yml": "Updated.",
})
// Install v1
srvV1 := mockRegistryServer(map[string][]byte{
"assistants/@test/modified": zipV1,
})
clientV1 := registry.New(srvV1.URL)
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
if err := mgrV1.Add("@test/modified", AddOptions{}); err != nil {
t.Fatal(err)
}
srvV1.Close()
// Modify prompts.yml locally
promptsPath := filepath.Join(appRoot, "assistants", "test", "modified", "prompts.yml")
os.WriteFile(promptsPath, []byte("My custom prompt."), 0644)
// Update to v2
srvV2 := mockRegistryServer(map[string][]byte{
"assistants/@test/modified": zipV2,
})
defer srvV2.Close()
clientV2 := registry.New(srvV2.URL)
mgrV2 := New(clientV2, appRoot, &common.AutoConfirmPrompter{})
err := mgrV2.Update("@test/modified", UpdateOptions{})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// prompts.yml should be preserved (locally modified)
data, _ := os.ReadFile(promptsPath)
if string(data) != "My custom prompt." {
t.Errorf("expected locally modified prompts.yml preserved, got: %s", data)
}
// New version should be saved as .new
newPath := promptsPath + ".new"
if _, err := os.Stat(newPath); err != nil {
t.Error("expected prompts.yml.new to exist")
}
newData, _ := os.ReadFile(newPath)
if string(newData) != "Updated." {
t.Errorf("expected .new file to contain new version, got: %s", newData)
}
}
func TestUpdateNotInstalled(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@test/nonexistent", UpdateOptions{})
if err == nil {
t.Fatal("expected error for not-installed package")
}
if !strings.Contains(err.Error(), "not installed") {
t.Errorf("expected 'not installed' error, got: %v", err)
}
}
func TestUpdateForkedPackage(t *testing.T) {
appRoot := t.TempDir()
// Set up a forked package in lockfile
lf := &common.RegistryYao{
Scope: "@local",
Packages: map[string]common.PackageInfo{
"@local/keeper": {
Type: common.TypeAssistant,
Version: "1.0.0",
ForkedFrom: "@yao/keeper",
Managed: common.BoolPtr(false),
},
},
}
common.SaveLockfile(appRoot, lf)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@local/keeper", UpdateOptions{})
if err == nil {
t.Fatal("expected error for forked package")
}
if !strings.Contains(err.Error(), "forked") {
t.Errorf("expected 'forked' error, got: %v", err)
}
}
func TestPushBasic(t *testing.T) {
appRoot := t.TempDir()
// Create assistant directory
assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644)
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("test prompt"), 0644)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.my-agent", PushOptions{Version: "1.0.0"})
if err != nil {
t.Fatalf("Push failed: %v", err)
}
}
func TestPushLocalScope(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("local.my-agent", PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected error for @local push")
}
if !strings.Contains(err.Error(), "@local") {
t.Errorf("expected @local rejection, got: %v", err)
}
}
func TestPushForce(t *testing.T) {
appRoot := t.TempDir()
assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644)
var deleteCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/yao-registry" {
json.NewEncoder(w).Encode(map[string]interface{}{
"registry": map[string]string{"version": "1.0.0", "api": "/v1"},
"types": []string{"assistants"},
})
return
}
if r.Method == http.MethodDelete {
deleteCalled = true
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
return
}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"type": "assistants", "scope": "@max",
"name": "my-agent", "version": "1.0.0", "digest": "sha256-forced",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.my-agent", PushOptions{Version: "1.0.0", Force: true})
if err != nil {
t.Fatalf("Force push failed: %v", err)
}
if !deleteCalled {
t.Error("expected DELETE to be called before PUT when Force=true")
}
}
func TestPushNoVersion(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.my-agent", PushOptions{})
if err == nil {
t.Fatal("expected error for missing version")
}
}
func TestForkFromInstalled(t *testing.T) {
appRoot := t.TempDir()
// Set up an installed package
assistantDir := filepath.Join(appRoot, "assistants", "yao", "keeper")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"keeper"}`), 0644)
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("keeper prompt"), 0644)
lf := &common.RegistryYao{
Scope: "@local",
Packages: map[string]common.PackageInfo{
"@yao/keeper": {
Type: common.TypeAssistant,
Version: "2.0.0",
},
},
}
common.SaveLockfile(appRoot, lf)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/keeper", ForkOptions{})
if err != nil {
t.Fatalf("Fork failed: %v", err)
}
// Verify forked directory
forkDir := filepath.Join(appRoot, "assistants", "local", "keeper")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected forked directory")
}
data, _ := os.ReadFile(filepath.Join(forkDir, "package.yao"))
if string(data) != `{"name":"keeper"}` {
t.Errorf("expected copied content, got: %s", data)
}
// Verify lockfile
lf, _ = common.LoadLockfile(appRoot)
pkg, ok := lf.GetPackage("@local/keeper")
if !ok {
t.Fatal("expected @local/keeper in lockfile")
}
if pkg.ForkedFrom != "@yao/keeper" {
t.Errorf("expected forked_from @yao/keeper, got %s", pkg.ForkedFrom)
}
if pkg.IsManaged() {
t.Error("expected managed=false")
}
if pkg.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
}
}
func TestForkFromRegistry(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "remote-agent", "3.0.0", nil, map[string]string{
"package.yao": `{"name":"remote-agent"}`,
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/remote-agent": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@test/remote-agent", ForkOptions{})
if err != nil {
t.Fatalf("Fork from registry failed: %v", err)
}
forkDir := filepath.Join(appRoot, "assistants", "local", "remote-agent")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected forked directory")
}
}
func TestForkTargetExists(t *testing.T) {
appRoot := t.TempDir()
// Create target directory
os.MkdirAll(filepath.Join(appRoot, "assistants", "local", "existing"), 0755)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/existing", ForkOptions{})
if err == nil {
t.Fatal("expected error when target exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("unexpected error: %v", err)
}
}
func TestForkCustomScope(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@yao", "keeper", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"keeper"}`,
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@yao/keeper": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/keeper", ForkOptions{TargetScope: "max"})
if err != nil {
t.Fatalf("Fork to custom scope failed: %v", err)
}
forkDir := filepath.Join(appRoot, "assistants", "max", "keeper")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected directory in max scope")
}
lf, _ := common.LoadLockfile(appRoot)
if _, ok := lf.GetPackage("@max/keeper"); !ok {
t.Error("expected @max/keeper in lockfile")
}
}
func TestScanDependencies(t *testing.T) {
appRoot := t.TempDir()
// Create assistant with MCP dependency
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-scan")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
"name":"test-scan",
"mcp": {
"servers": [
{"server_id": "yao.rag-tools"}
]
}
}`), 0644)
// Create scoped MCP directory so it gets picked up
os.MkdirAll(filepath.Join(appRoot, "mcps", "yao", "rag-tools"), 0755)
deps, err := ScanDependencies(assistantDir, appRoot)
if err != nil {
t.Fatal(err)
}
if _, ok := deps["@yao/rag-tools"]; !ok {
t.Error("expected @yao/rag-tools in scanned dependencies")
}
}
func TestScanDependenciesSkipUnscoped(t *testing.T) {
appRoot := t.TempDir()
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-local")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
"name":"test-local",
"mcp": {
"servers": [
{"server_id": "echo"}
]
}
}`), 0644)
deps, err := ScanDependencies(assistantDir, appRoot)
if err != nil {
t.Fatal(err)
}
// "echo" has no dot, so IDFromYaoID should fail and it should be skipped
if len(deps) != 0 {
t.Errorf("expected no dependencies for unscoped MCP, got %v", deps)
}
}