diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 98b238e1..e9e87832 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1367,3 +1367,166 @@ jobs: issue_number: issue_number, body: '✨DONE✨ db: ${{ matrix.db }} redis: ${{ matrix.redis }} mongo: ${{ matrix.mongo }} passed.' }); + + # ============================================================================= + # Registry Client SDK Tests (requires Yao Registry Docker service) + # ============================================================================= + RegistryTest: + runs-on: ubuntu-latest + services: + yao-registry: + image: yaoapp/registry:latest + ports: + - "8080:8080" + env: + REGISTRY_INIT_USER: yaoagents + REGISTRY_INIT_PASS: yaoagents + strategy: + matrix: + go: ["1.25"] + if: > + ${{ github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' }} + steps: + - name: "Download artifact" + uses: actions/github-script@v7 + with: + script: | + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr" + })[0]; + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data)); + + - name: "Read NR & SHA" + run: | + unzip pr.zip + cat NR + cat SHA + echo HEAD=$(cat SHA) >> $GITHUB_ENV + echo NR=$(cat NR) >> $GITHUB_ENV + + - name: "Comment on PR" + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { NR } = process.env + var issue_number = NR; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + body: '🤖 Registry Client SDK Tests running...' + }); + + - name: Wait for Registry + run: | + for i in $(seq 1 15); do + if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then + echo "Registry is ready" + break + fi + echo "Waiting for registry... ($i)" + sleep 1 + done + + - name: Checkout Kun + uses: actions/checkout@v4 + with: + repository: yaoapp/kun + path: kun + + - name: Checkout Xun + uses: actions/checkout@v4 + with: + repository: yaoapp/xun + path: xun + + - name: Checkout Gou + uses: actions/checkout@v4 + with: + repository: yaoapp/gou + path: gou + + - name: Checkout V8Go + uses: actions/checkout@v4 + with: + repository: yaoapp/v8go + path: v8go + + - name: Unzip libv8 + run: | + files=$(find ./v8go -name "libv8*.zip") + for file in $files; do + dir=$(dirname "$file") + echo "Extracting $file to directory $dir" + unzip -o -d $dir $file + rm -rf $dir/__MACOSX + done + + - name: Checkout Demo App + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-dev-app + path: app + + - name: Checkout Extension + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-extensions-dev + path: extension + + - name: Move Dependencies + run: | + mv kun ../ + mv xun ../ + mv gou ../ + mv v8go ../ + mv app ../ + mv extension ../ + + - name: Checkout pull request HEAD commit + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD }} + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Run Registry Client Tests + env: + YAO_REGISTRY_URL: http://localhost:8080 + run: make unit-test-registry + + - name: Codecov Report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: "Comment on PR - Registry Tests Done" + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { NR } = process.env + var issue_number = NR; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + body: '✅ Registry Client SDK Tests passed!' + }); diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 0442c757..93d8cb46 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -1031,3 +1031,104 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + + # ============================================================================= + # Registry Client SDK Tests (requires Yao Registry Docker service) + # ============================================================================= + registry-test: + runs-on: ubuntu-latest + services: + yao-registry: + image: yaoapp/registry:latest + ports: + - "8080:8080" + env: + REGISTRY_INIT_USER: yaoagents + REGISTRY_INIT_PASS: yaoagents + strategy: + matrix: + go: ["1.25"] + steps: + - name: Wait for Registry + run: | + for i in $(seq 1 15); do + if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then + echo "Registry is ready" + break + fi + echo "Waiting for registry... ($i)" + sleep 1 + done + + - name: Checkout Kun + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_KUN }} + path: kun + + - name: Checkout Xun + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_XUN }} + path: xun + + - name: Checkout Gou + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_GOU }} + path: gou + + - name: Checkout V8Go + uses: actions/checkout@v4 + with: + repository: yaoapp/v8go + path: v8go + + - name: Unzip libv8 + run: | + files=$(find ./v8go -name "libv8*.zip") + for file in $files; do + dir=$(dirname "$file") + echo "Extracting $file to directory $dir" + unzip -o -d $dir $file + rm -rf $dir/__MACOSX + done + + - name: Checkout Demo App + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-dev-app + path: app + + - name: Checkout Extension + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-extensions-dev + path: extension + + - name: Move Dependencies + run: | + mv kun ../ + mv xun ../ + mv gou ../ + mv v8go ../ + mv app ../ + mv extension ../ + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Run Registry Client Tests + env: + YAO_REGISTRY_URL: http://localhost:8080 + run: make unit-test-registry + + - name: Codecov Report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 0406c0e5..e85e3e17 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ agent/robot/DESIGN-V2.md tg-session.json tg-login tg-send +registry/data/ diff --git a/Makefile b/Makefile index ab64c1c0..e2cce08e 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,9 @@ NOW := $(shell date +"%FT%T%z") OS := $(shell uname) # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) -TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*' | awk '!/\/tests\// || /openapi\/tests/') -# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, and integrations which require external services) -TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations' | awk '!/\/tests\// || /openapi\/tests/') +TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry' | awk '!/\/tests\// || /openapi\/tests/') +# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, and integrations which require external services) +TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry' | awk '!/\/tests\// || /openapi\/tests/') # Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys) and robot packages (tested in robot job) TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/search/handlers/web|agent/robot/') # KB tests (kb) @@ -174,6 +174,27 @@ unit-test-robot: fi; \ done +# Registry Client Test (requires Yao Registry service) +.PHONY: unit-test-registry +unit-test-registry: + echo "mode: count" > coverage.out + $(GO) test -v -timeout=2m -covermode=count -coverprofile=profile.out ./registry/... > tmp.out; \ + cat tmp.out; \ + if grep -q "^--- FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "^FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "^panic:" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + fi; \ + if [ -f profile.out ]; then \ + cat profile.out | grep -v "mode:" >> coverage.out; \ + rm profile.out; \ + fi + # Sandbox Unit Test (requires Docker) .PHONY: unit-test-sandbox unit-test-sandbox: diff --git a/registry/README.md b/registry/README.md new file mode 100644 index 00000000..821778d4 --- /dev/null +++ b/registry/README.md @@ -0,0 +1,56 @@ +# registry + +Go client SDK for [Yao Registry](https://github.com/YaoApp/registry). + +## Usage + +```go +import "github.com/yaoapp/yao/registry" + +// Only the server URL is required. API prefix is auto-discovered +// via /.well-known/yao-registry on the first call. +c := registry.New("https://registry.yaoagents.com", + registry.WithAuth("user", "pass"), // optional, for push/delete +) + +// Push a .yao.zip package +result, err := c.Push("assistants", "@yao", "hello", "1.0.0", zipBytes) + +// Pull (by version or dist-tag) +data, digest, err := c.Pull("assistants", "@yao", "hello", "latest") + +// Query +pack, err := c.GetPackument("assistants", "@yao", "hello") +ver, err := c.GetVersion("assistants", "@yao", "hello", "1.0.0") +list, err := c.Search("hello", "assistants", 1, 20) + +// Dependencies +deps, err := c.GetDependencies("assistants", "@yao", "hello", "1.0.0", true) + +// Dist-tags +c.SetTag("assistants", "@yao", "hello", "stable", "1.0.0") +c.DeleteTag("assistants", "@yao", "hello", "stable") + +// Delete +c.DeleteVersion("assistants", "@yao", "hello", "1.0.0") +``` + +## Options + +| Option | Description | +|--------|-------------| +| `WithAuth(user, pass)` | Basic Auth for push/delete | +| `WithHTTPClient(hc)` | Custom `*http.Client` | +| `WithTimeout(d)` | HTTP timeout | + +## Environment + +Tests require a running registry server. Set `YAO_REGISTRY_URL` (default `http://localhost:8080`) and create user `yaoagents`/`yaoagents`. + +```bash +# Start registry with test user +REGISTRY_INIT_USER=yaoagents REGISTRY_INIT_PASS=yaoagents registry start + +# Run tests +go test ./registry/... -v +``` diff --git a/registry/client.go b/registry/client.go new file mode 100644 index 00000000..2f23e6f0 --- /dev/null +++ b/registry/client.go @@ -0,0 +1,494 @@ +// Package registry provides a client SDK for the Yao Registry HTTP API. +// It supports push, pull, search, version management, dist-tags, +// dependency queries, and package deletion with Basic Auth. +package registry + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Client talks to a Yao Registry server over HTTP. +// On first API call it discovers the API prefix via /.well-known/yao-registry +// so callers only need to provide the server root URL (e.g. "https://registry.yaoagents.com"). +type Client struct { + baseURL string + apiPrefix string // resolved from well-known, e.g. "/v1" + discoverOnce sync.Once + username string + password string + httpClient *http.Client +} + +// Option configures a Client. +type Option func(*Client) + +// WithAuth sets Basic Auth credentials for push/delete operations. +func WithAuth(username, password string) Option { + return func(c *Client) { + c.username = username + c.password = password + } +} + +// WithHTTPClient overrides the default http.Client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.httpClient = hc } +} + +// WithTimeout sets the HTTP client timeout. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { c.httpClient.Timeout = d } +} + +// New creates a registry client. serverURL is the root URL users configure, +// e.g. "http://localhost:8080" or "https://registry.yaoagents.com". +// The actual API prefix is auto-discovered via /.well-known/yao-registry. +func New(serverURL string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(serverURL, "/"), + apiPrefix: "/v1", // sensible default, overridden by discovery + httpClient: &http.Client{Timeout: 60 * time.Second}, + } + for _, o := range opts { + o(c) + } + return c +} + +// ensureDiscovered runs well-known discovery exactly once (thread-safe). +func (c *Client) ensureDiscovered() { + c.discoverOnce.Do(func() { + var info RegistryInfo + if err := c.doGet("/.well-known/yao-registry", nil, &info); err == nil && info.Registry.API != "" { + c.apiPrefix = strings.TrimRight(info.Registry.API, "/") + } + }) +} + +// --- Response types --- + +// RegistryInfo is returned by the discovery endpoint. +type RegistryInfo struct { + Registry struct { + Version string `json:"version"` + API string `json:"api"` + } `json:"registry"` + Types []string `json:"types"` +} + +// ServerInfo is returned by GET /v1/. +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// PushResult is returned after a successful push. +type PushResult struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Version string `json:"version"` + Digest string `json:"digest"` +} + +// DeleteResult is returned after a successful version delete. +type DeleteResult struct { + Deleted string `json:"deleted"` + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` +} + +// TagResult is returned after setting a dist-tag. +type TagResult struct { + Tag string `json:"tag"` + Version string `json:"version"` +} + +// TagDeleteResult is returned after deleting a dist-tag. +type TagDeleteResult struct { + Deleted string `json:"deleted"` +} + +// ListResult is returned by list and search endpoints. +type ListResult struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pagesize"` + Packages []json.RawMessage `json:"packages"` +} + +// Packument is the full package metadata response. +type Packument struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Description string `json:"description"` + Keywords []string `json:"keywords"` + DistTags map[string]string `json:"dist_tags"` + Versions map[string]json.RawMessage `json:"versions"` + License string `json:"license,omitempty"` + Homepage string `json:"homepage,omitempty"` + Readme string `json:"readme,omitempty"` + Author json.RawMessage `json:"author,omitempty"` + Maintainers json.RawMessage `json:"maintainers,omitempty"` + Repository json.RawMessage `json:"repository,omitempty"` + Bugs json.RawMessage `json:"bugs,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// VersionDetail is returned for a single version query. +type VersionDetail struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Version string `json:"version"` + Digest string `json:"digest"` + Size int64 `json:"size"` + Dependencies []Dependency `json:"dependencies"` + Metadata map[string]interface{} `json:"metadata"` + CreatedAt string `json:"created_at"` + Artifacts []Artifact `json:"artifacts,omitempty"` +} + +// Dependency represents a package dependency. +type Dependency struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Version string `json:"version"` +} + +// DependencyList wraps the dependencies response. +type DependencyList struct { + Dependencies []json.RawMessage `json:"dependencies"` +} + +// DependentList wraps the dependents response. +type DependentList struct { + Dependents []json.RawMessage `json:"dependents"` +} + +// Artifact represents a platform-specific release artifact. +type Artifact struct { + OS string `json:"os"` + Arch string `json:"arch"` + Variant string `json:"variant"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// APIError is returned when the server responds with an error. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("registry: HTTP %d: %s", e.StatusCode, e.Message) +} + +// --- Discovery --- + +// Discover calls GET /.well-known/yao-registry. +func (c *Client) Discover() (*RegistryInfo, error) { + var info RegistryInfo + if err := c.doGet("/.well-known/yao-registry", nil, &info); err != nil { + return nil, err + } + return &info, nil +} + +// Info calls GET {apiPrefix}/. +func (c *Client) Info() (*ServerInfo, error) { + c.ensureDiscovered() + var info ServerInfo + if err := c.doGet(c.apiPrefix+"/", nil, &info); err != nil { + return nil, err + } + return &info, nil +} + +// --- List & Search --- + +// List calls GET {apiPrefix}/:type with optional filters. +func (c *Client) List(pkgType string, scope string, query string, page, pageSize int) (*ListResult, error) { + c.ensureDiscovered() + params := url.Values{} + if scope != "" { + params.Set("scope", scope) + } + if query != "" { + params.Set("q", query) + } + if page > 0 { + params.Set("page", fmt.Sprintf("%d", page)) + } + if pageSize > 0 { + params.Set("pagesize", fmt.Sprintf("%d", pageSize)) + } + var result ListResult + if err := c.doGet(c.apiPrefix+"/"+pkgType, params, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Search calls GET {apiPrefix}/search. +func (c *Client) Search(q string, pkgType string, page, pageSize int) (*ListResult, error) { + c.ensureDiscovered() + params := url.Values{"q": {q}} + if pkgType != "" { + params.Set("type", pkgType) + } + if page > 0 { + params.Set("page", fmt.Sprintf("%d", page)) + } + if pageSize > 0 { + params.Set("pagesize", fmt.Sprintf("%d", pageSize)) + } + var result ListResult + if err := c.doGet(c.apiPrefix+"/search", params, &result); err != nil { + return nil, err + } + return &result, nil +} + +// --- Package metadata --- + +// GetPackument calls GET {apiPrefix}/:type/:scope/:name. +func (c *Client) GetPackument(pkgType, scope, name string) (*Packument, error) { + c.ensureDiscovered() + var p Packument + path := fmt.Sprintf("%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name) + if err := c.doGet(path, nil, &p); err != nil { + return nil, err + } + return &p, nil +} + +// GetVersion calls GET {apiPrefix}/:type/:scope/:name/:version. +func (c *Client) GetVersion(pkgType, scope, name, version string) (*VersionDetail, error) { + c.ensureDiscovered() + var v VersionDetail + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) + if err := c.doGet(path, nil, &v); err != nil { + return nil, err + } + return &v, nil +} + +// --- Dependencies --- + +// GetDependencies calls GET {apiPrefix}/:type/:scope/:name/:version/dependencies. +func (c *Client) GetDependencies(pkgType, scope, name, version string, recursive bool) (*DependencyList, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s/dependencies", c.apiPrefix, pkgType, scope, name, version) + params := url.Values{} + if recursive { + params.Set("recursive", "true") + } + var dl DependencyList + if err := c.doGet(path, params, &dl); err != nil { + return nil, err + } + return &dl, nil +} + +// GetDependents calls GET {apiPrefix}/:type/:scope/:name/dependents. +func (c *Client) GetDependents(pkgType, scope, name string) (*DependentList, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/dependents", c.apiPrefix, pkgType, scope, name) + var dl DependentList + if err := c.doGet(path, nil, &dl); err != nil { + return nil, err + } + return &dl, nil +} + +// --- Push & Pull --- + +// Push uploads a .yao.zip package via PUT {apiPrefix}/:type/:scope/:name/:version. +func (c *Client) Push(pkgType, scope, name, version string, zipData []byte) (*PushResult, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) + req, err := http.NewRequest(http.MethodPut, c.baseURL+path, bytes.NewReader(zipData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/zip") + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, parseError(resp) + } + + var result PushResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + +// Pull downloads a .yao.zip via GET {apiPrefix}/:type/:scope/:name/:version/pull. +// The version parameter can be a semver or a dist-tag name. +func (c *Client) Pull(pkgType, scope, name, version string) ([]byte, string, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s/pull", c.apiPrefix, pkgType, scope, name, version) + resp, err := c.httpClient.Get(c.baseURL + path) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, "", parseError(resp) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + digest := resp.Header.Get("X-Digest") + return data, digest, nil +} + +// --- Tags --- + +// SetTag calls PUT {apiPrefix}/:type/:scope/:name/tags/:tag. +func (c *Client) SetTag(pkgType, scope, name, tag, version string) (*TagResult, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/tags/%s", c.apiPrefix, pkgType, scope, name, tag) + body, _ := json.Marshal(map[string]string{"version": version}) + + req, err := http.NewRequest(http.MethodPut, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + + var result TagResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteTag calls DELETE {apiPrefix}/:type/:scope/:name/tags/:tag. +func (c *Client) DeleteTag(pkgType, scope, name, tag string) (*TagDeleteResult, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/tags/%s", c.apiPrefix, pkgType, scope, name, tag) + req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil) + if err != nil { + return nil, err + } + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + + var result TagDeleteResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + +// --- Delete --- + +// DeleteVersion calls DELETE {apiPrefix}/:type/:scope/:name/:version. +func (c *Client) DeleteVersion(pkgType, scope, name, version string) (*DeleteResult, error) { + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) + req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil) + if err != nil { + return nil, err + } + c.setAuth(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + + var result DeleteResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + +// --- Internal helpers --- + +func (c *Client) setAuth(req *http.Request) { + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } +} + +func (c *Client) doGet(path string, params url.Values, out interface{}) error { + u := c.baseURL + path + if len(params) > 0 { + u += "?" + params.Encode() + } + resp, err := c.httpClient.Get(u) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +func parseError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" { + return &APIError{StatusCode: resp.StatusCode, Message: errResp.Error} + } + return &APIError{StatusCode: resp.StatusCode, Message: string(body)} +} diff --git a/registry/client_test.go b/registry/client_test.go new file mode 100644 index 00000000..4940a8eb --- /dev/null +++ b/registry/client_test.go @@ -0,0 +1,1042 @@ +package registry_test + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/yaoapp/yao/registry" + "github.com/yaoapp/yao/registry/testdata" +) + +const ( + testScope = "@test" +) + +func serverURL() string { + if u := os.Getenv("YAO_REGISTRY_URL"); u != "" { + return u + } + return "http://localhost:8080" +} + +func newClient() *registry.Client { + return registry.New(serverURL(), + registry.WithAuth("yaoagents", "yaoagents"), + ) +} + +func newPublicClient() *registry.Client { + return registry.New(serverURL()) +} + +// cleanup removes a version and ignores 404 errors. +func cleanup(c *registry.Client, pkgType, scope, name, version string) { + c.DeleteVersion(pkgType, scope, name, version) +} + +// --- Discovery --- + +func TestDiscover(t *testing.T) { + c := newPublicClient() + info, err := c.Discover() + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + if info.Registry.Version == "" { + t.Error("expected non-empty registry version") + } + if info.Registry.API == "" { + t.Error("expected non-empty API path") + } + if len(info.Types) == 0 { + t.Error("expected at least one supported type") + } +} + +func TestInfo(t *testing.T) { + c := newPublicClient() + info, err := c.Info() + if err != nil { + t.Fatalf("Info failed: %v", err) + } + if info.Name == "" { + t.Error("expected non-empty name") + } + if info.Version == "" { + t.Error("expected non-empty version") + } +} + +// --- Assistant CRUD --- + +func TestAssistantCRUD(t *testing.T) { + c := newClient() + pkgType := "assistants" + name := "test-assistant" + + zip10, err := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", + Scope: testScope, + Name: name, + Version: "1.0.0", + Description: "Test assistant for unit tests", + Keywords: []string{"test", "assistant"}, + License: "MIT", + Author: &testdata.ManifestAuthor{Name: "Test", Email: "test@test.com"}, + }, map[string]string{ + "prompts/main.md": "You are a test assistant.", + }) + if err != nil { + t.Fatalf("BuildZip: %v", err) + } + + defer cleanup(c, pkgType, testScope, name, "1.0.0") + defer cleanup(c, pkgType, testScope, name, "1.1.0") + + // Push v1.0.0 + result, err := c.Push(pkgType, testScope, name, "1.0.0", zip10) + if err != nil { + t.Fatalf("Push 1.0.0 failed: %v", err) + } + if result.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", result.Version) + } + if result.Digest == "" { + t.Error("expected non-empty digest") + } + if result.Type != pkgType { + t.Errorf("expected type %s, got %s", pkgType, result.Type) + } + + // Push v1.1.0 + zip11, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", + Scope: testScope, + Name: name, + Version: "1.1.0", + Description: "Updated test assistant", + }, nil) + result, err = c.Push(pkgType, testScope, name, "1.1.0", zip11) + if err != nil { + t.Fatalf("Push 1.1.0 failed: %v", err) + } + if result.Version != "1.1.0" { + t.Errorf("expected version 1.1.0, got %s", result.Version) + } + + // Get packument + pack, err := c.GetPackument(pkgType, testScope, name) + if err != nil { + t.Fatalf("GetPackument failed: %v", err) + } + if pack.Type != pkgType { + t.Errorf("expected type %s, got %s", pkgType, pack.Type) + } + if pack.Scope != testScope { + t.Errorf("expected scope %s, got %s", testScope, pack.Scope) + } + if pack.Name != name { + t.Errorf("expected name %s, got %s", name, pack.Name) + } + if len(pack.Versions) < 2 { + t.Errorf("expected at least 2 versions, got %d", len(pack.Versions)) + } + if pack.DistTags["latest"] != "1.1.0" { + t.Errorf("expected latest=1.1.0, got %s", pack.DistTags["latest"]) + } + + // Get single version + ver, err := c.GetVersion(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("GetVersion failed: %v", err) + } + if ver.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", ver.Version) + } + if ver.Digest == "" { + t.Error("expected non-empty digest in version detail") + } + + // Pull + data, digest, err := c.Pull(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("Pull failed: %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty pull data") + } + if digest == "" { + t.Error("expected non-empty digest header from pull") + } + + // Pull by latest tag + dataLatest, _, err := c.Pull(pkgType, testScope, name, "latest") + if err != nil { + t.Fatalf("Pull latest failed: %v", err) + } + if len(dataLatest) == 0 { + t.Error("expected non-empty pull data for latest") + } + + // Delete v1.0.0 + del, err := c.DeleteVersion(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("DeleteVersion 1.0.0 failed: %v", err) + } + if del.Deleted != "1.0.0" { + t.Errorf("expected deleted=1.0.0, got %s", del.Deleted) + } + + // Verify v1.0.0 is gone + _, err = c.GetVersion(pkgType, testScope, name, "1.0.0") + if err == nil { + t.Error("expected error after deleting version 1.0.0") + } + + // Delete v1.1.0 + _, err = c.DeleteVersion(pkgType, testScope, name, "1.1.0") + if err != nil { + t.Fatalf("DeleteVersion 1.1.0 failed: %v", err) + } +} + +// --- MCP Tool CRUD --- + +func TestMCPToolCRUD(t *testing.T) { + c := newClient() + pkgType := "mcps" + name := "test-mcp-tool" + + zipData, err := testdata.BuildZip(&testdata.Manifest{ + Type: "mcp", + Scope: testScope, + Name: name, + Version: "2.0.0", + Description: "Test MCP tool for SDK tests", + Keywords: []string{"test", "mcp"}, + Engines: map[string]string{"yao": ">=0.10.0"}, + }, map[string]string{ + "tools.json": `[{"name":"echo","description":"Echo tool"}]`, + "scripts/run.js": "function main(args) { return args; }", + }) + if err != nil { + t.Fatalf("BuildZip: %v", err) + } + + defer cleanup(c, pkgType, testScope, name, "2.0.0") + + result, err := c.Push(pkgType, testScope, name, "2.0.0", zipData) + if err != nil { + t.Fatalf("Push failed: %v", err) + } + if result.Scope != testScope { + t.Errorf("expected scope %s, got %s", testScope, result.Scope) + } + + // Pull the MCP tool + data, _, err := c.Pull(pkgType, testScope, name, "2.0.0") + if err != nil { + t.Fatalf("Pull failed: %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty data") + } + + // Get version detail + ver, err := c.GetVersion(pkgType, testScope, name, "2.0.0") + if err != nil { + t.Fatalf("GetVersion failed: %v", err) + } + if ver.Size <= 0 { + t.Error("expected positive size") + } + + // Delete + _, err = c.DeleteVersion(pkgType, testScope, name, "2.0.0") + if err != nil { + t.Fatalf("DeleteVersion failed: %v", err) + } +} + +// --- Robot CRUD --- + +func TestRobotCRUD(t *testing.T) { + c := newClient() + pkgType := "robots" + name := "test-robot" + + zipData, err := testdata.BuildZip(&testdata.Manifest{ + Type: "robot", + Scope: testScope, + Name: name, + Version: "0.5.0", + Description: "Test robot for SDK tests", + }, map[string]string{ + "robot.json": `{"name":"test-robot","model":"gpt-4o"}`, + }) + if err != nil { + t.Fatalf("BuildZip: %v", err) + } + + defer cleanup(c, pkgType, testScope, name, "0.5.0") + + result, err := c.Push(pkgType, testScope, name, "0.5.0", zipData) + if err != nil { + t.Fatalf("Push failed: %v", err) + } + if result.Name != name { + t.Errorf("expected name %s, got %s", name, result.Name) + } + + // Packument + pack, err := c.GetPackument(pkgType, testScope, name) + if err != nil { + t.Fatalf("GetPackument failed: %v", err) + } + if pack.DistTags["latest"] != "0.5.0" { + t.Errorf("expected latest=0.5.0, got %s", pack.DistTags["latest"]) + } + + // Delete + _, err = c.DeleteVersion(pkgType, testScope, name, "0.5.0") + if err != nil { + t.Fatalf("DeleteVersion failed: %v", err) + } +} + +// --- Dist-Tags --- + +func TestDistTags(t *testing.T) { + c := newClient() + pkgType := "assistants" + name := "test-tags" + + zip10, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: name, Version: "1.0.0", + }, nil) + zip20, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: name, Version: "2.0.0", + }, nil) + + defer cleanup(c, pkgType, testScope, name, "1.0.0") + defer cleanup(c, pkgType, testScope, name, "2.0.0") + + c.Push(pkgType, testScope, name, "1.0.0", zip10) + c.Push(pkgType, testScope, name, "2.0.0", zip20) + + // Set a custom tag + tagResult, err := c.SetTag(pkgType, testScope, name, "stable", "1.0.0") + if err != nil { + t.Fatalf("SetTag failed: %v", err) + } + if tagResult.Tag != "stable" { + t.Errorf("expected tag=stable, got %s", tagResult.Tag) + } + if tagResult.Version != "1.0.0" { + t.Errorf("expected version=1.0.0, got %s", tagResult.Version) + } + + // Verify tag in packument + pack, err := c.GetPackument(pkgType, testScope, name) + if err != nil { + t.Fatalf("GetPackument failed: %v", err) + } + if pack.DistTags["stable"] != "1.0.0" { + t.Errorf("expected stable=1.0.0, got %s", pack.DistTags["stable"]) + } + if pack.DistTags["latest"] != "2.0.0" { + t.Errorf("expected latest=2.0.0, got %s", pack.DistTags["latest"]) + } + + // Pull by custom tag + data, _, err := c.Pull(pkgType, testScope, name, "stable") + if err != nil { + t.Fatalf("Pull by stable tag failed: %v", err) + } + if len(data) == 0 { + t.Error("expected data from pull by tag") + } + + // Delete the custom tag + delTag, err := c.DeleteTag(pkgType, testScope, name, "stable") + if err != nil { + t.Fatalf("DeleteTag failed: %v", err) + } + if delTag.Deleted != "stable" { + t.Errorf("expected deleted=stable, got %s", delTag.Deleted) + } + + // Verify tag removed + pack, _ = c.GetPackument(pkgType, testScope, name) + if _, ok := pack.DistTags["stable"]; ok { + t.Error("expected stable tag to be removed") + } + + // Cleanup + c.DeleteVersion(pkgType, testScope, name, "2.0.0") + c.DeleteVersion(pkgType, testScope, name, "1.0.0") +} + +// --- Dependencies --- + +func TestDependencies(t *testing.T) { + c := newClient() + + // Create a base MCP tool (no deps) + mcpZip, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "mcp", Scope: testScope, Name: "dep-base", Version: "1.0.0", + Description: "Base MCP dependency", + }, map[string]string{"tools.json": `[]`}) + + // Create an assistant that depends on the MCP tool + astZip, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: "dep-consumer", Version: "1.0.0", + Description: "Assistant that depends on MCP tool", + Dependencies: []testdata.ManifestDep{ + {Type: "mcp", Scope: testScope, Name: "dep-base", Version: "^1.0.0"}, + }, + }, map[string]string{"prompts/main.md": "Hello"}) + + defer cleanup(c, "mcps", testScope, "dep-base", "1.0.0") + defer cleanup(c, "assistants", testScope, "dep-consumer", "1.0.0") + + _, err := c.Push("mcps", testScope, "dep-base", "1.0.0", mcpZip) + if err != nil { + t.Fatalf("Push MCP dep-base failed: %v", err) + } + _, err = c.Push("assistants", testScope, "dep-consumer", "1.0.0", astZip) + if err != nil { + t.Fatalf("Push assistant dep-consumer failed: %v", err) + } + + // Query dependencies + deps, err := c.GetDependencies("assistants", testScope, "dep-consumer", "1.0.0", false) + if err != nil { + t.Fatalf("GetDependencies failed: %v", err) + } + if len(deps.Dependencies) == 0 { + t.Error("expected at least 1 dependency") + } + + // Query dependents of the MCP tool + dependents, err := c.GetDependents("mcps", testScope, "dep-base") + if err != nil { + t.Fatalf("GetDependents failed: %v", err) + } + if len(dependents.Dependents) == 0 { + t.Error("expected at least 1 dependent") + } + + // Cleanup + c.DeleteVersion("assistants", testScope, "dep-consumer", "1.0.0") + c.DeleteVersion("mcps", testScope, "dep-base", "1.0.0") +} + +// --- List & Search --- + +func TestListAndSearch(t *testing.T) { + c := newClient() + pkgType := "assistants" + name := "test-searchable" + + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", + Scope: testScope, + Name: name, + Version: "1.0.0", + Description: "A searchable test assistant", + Keywords: []string{"searchable", "e2e"}, + }, nil) + + defer cleanup(c, pkgType, testScope, name, "1.0.0") + + _, err := c.Push(pkgType, testScope, name, "1.0.0", zipData) + if err != nil { + t.Fatalf("Push failed: %v", err) + } + + // List assistants + list, err := c.List(pkgType, "", "", 1, 20) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if list.Total < 1 { + t.Errorf("expected at least 1 package, got %d", list.Total) + } + if list.Page != 1 { + t.Errorf("expected page=1, got %d", list.Page) + } + + // List with scope filter + listScoped, err := c.List(pkgType, testScope, "", 1, 20) + if err != nil { + t.Fatalf("List with scope failed: %v", err) + } + if listScoped.Total < 1 { + t.Errorf("expected at least 1 package in scope %s, got %d", testScope, listScoped.Total) + } + + // Search + search, err := c.Search("searchable", "", 1, 20) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if search.Total < 1 { + t.Errorf("expected at least 1 search result, got %d", search.Total) + } + + // Search with type filter + searchTyped, err := c.Search("searchable", pkgType, 1, 20) + if err != nil { + t.Fatalf("Search with type failed: %v", err) + } + if searchTyped.Total < 1 { + t.Errorf("expected at least 1 typed search result, got %d", searchTyped.Total) + } + + // Cleanup + c.DeleteVersion(pkgType, testScope, name, "1.0.0") +} + +// --- Options coverage --- + +func TestClientOptions(t *testing.T) { + hc := &http.Client{Timeout: 10 * time.Second} + c := registry.New(serverURL(), + registry.WithAuth("u", "p"), + registry.WithHTTPClient(hc), + registry.WithTimeout(30*time.Second), + ) + // Verify the client works (at least doesn't panic) + _, err := c.Discover() + if err != nil { + t.Fatalf("Discover with custom options failed: %v", err) + } +} + +func TestAPIErrorString(t *testing.T) { + err := ®istry.APIError{StatusCode: 404, Message: "not found"} + s := err.Error() + if s != "registry: HTTP 404: not found" { + t.Errorf("unexpected error string: %s", s) + } +} + +func TestNetworkError(t *testing.T) { + c := registry.New("http://127.0.0.1:19999") + + _, err := c.Discover() + if err == nil { + t.Error("expected network error for Discover") + } + _, err = c.Info() + if err == nil { + t.Error("expected network error for Info") + } + _, err = c.List("assistants", "", "", 1, 20) + if err == nil { + t.Error("expected network error for List") + } + _, err = c.Search("q", "", 1, 20) + if err == nil { + t.Error("expected network error for Search") + } + _, err = c.GetPackument("assistants", "@x", "y") + if err == nil { + t.Error("expected network error for GetPackument") + } + _, err = c.GetVersion("assistants", "@x", "y", "1.0.0") + if err == nil { + t.Error("expected network error for GetVersion") + } + _, err = c.GetDependencies("assistants", "@x", "y", "1.0.0", true) + if err == nil { + t.Error("expected network error for GetDependencies") + } + _, err = c.GetDependents("assistants", "@x", "y") + if err == nil { + t.Error("expected network error for GetDependents") + } + _, _, err = c.Pull("assistants", "@x", "y", "1.0.0") + if err == nil { + t.Error("expected network error for Pull") + } + _, err = c.Push("assistants", "@x", "y", "1.0.0", []byte("data")) + if err == nil { + t.Error("expected network error for Push") + } + _, err = c.SetTag("assistants", "@x", "y", "t", "1.0.0") + if err == nil { + t.Error("expected network error for SetTag") + } + _, err = c.DeleteTag("assistants", "@x", "y", "t") + if err == nil { + t.Error("expected network error for DeleteTag") + } + _, err = c.DeleteVersion("assistants", "@x", "y", "1.0.0") + if err == nil { + t.Error("expected network error for DeleteVersion") + } +} + +func TestRecursiveDependencies(t *testing.T) { + c := newClient() + + base, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "mcp", Scope: testScope, Name: "recurse-base", Version: "1.0.0", + }, nil) + mid, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: "recurse-mid", Version: "1.0.0", + Dependencies: []testdata.ManifestDep{ + {Type: "mcp", Scope: testScope, Name: "recurse-base", Version: "^1.0.0"}, + }, + }, nil) + top, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "robot", Scope: testScope, Name: "recurse-top", Version: "1.0.0", + Dependencies: []testdata.ManifestDep{ + {Type: "assistant", Scope: testScope, Name: "recurse-mid", Version: "^1.0.0"}, + }, + }, nil) + + defer cleanup(c, "mcps", testScope, "recurse-base", "1.0.0") + defer cleanup(c, "assistants", testScope, "recurse-mid", "1.0.0") + defer cleanup(c, "robots", testScope, "recurse-top", "1.0.0") + + c.Push("mcps", testScope, "recurse-base", "1.0.0", base) + c.Push("assistants", testScope, "recurse-mid", "1.0.0", mid) + c.Push("robots", testScope, "recurse-top", "1.0.0", top) + + deps, err := c.GetDependencies("robots", testScope, "recurse-top", "1.0.0", true) + if err != nil { + t.Fatalf("GetDependencies recursive failed: %v", err) + } + if len(deps.Dependencies) == 0 { + t.Error("expected recursive dependencies") + } + + c.DeleteVersion("robots", testScope, "recurse-top", "1.0.0") + c.DeleteVersion("assistants", testScope, "recurse-mid", "1.0.0") + c.DeleteVersion("mcps", testScope, "recurse-base", "1.0.0") +} + +// --- Error handling --- + +func TestPushWithoutAuth(t *testing.T) { + c := newPublicClient() + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: "noauth", Version: "1.0.0", + }, nil) + + _, err := c.Push("assistants", testScope, "noauth", "1.0.0", zipData) + if err == nil { + t.Fatal("expected error when pushing without auth") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != 401 { + t.Errorf("expected 401, got %d", apiErr.StatusCode) + } +} + +func TestGetNonExistentPackage(t *testing.T) { + c := newPublicClient() + _, err := c.GetPackument("assistants", testScope, "does-not-exist") + if err == nil { + t.Fatal("expected error for non-existent package") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestPullNonExistentVersion(t *testing.T) { + c := newPublicClient() + _, _, err := c.Pull("assistants", testScope, "does-not-exist", "9.9.9") + if err == nil { + t.Fatal("expected error for non-existent version pull") + } +} + +func TestInvalidType(t *testing.T) { + c := newPublicClient() + _, err := c.List("invalidtype", "", "", 1, 20) + if err == nil { + t.Fatal("expected error for invalid type") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 400 { + t.Errorf("expected 400, got %d", apiErr.StatusCode) + } +} + +func TestDeleteNonExistentTag(t *testing.T) { + c := newClient() + name := "tag-noexist" + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: name, Version: "1.0.0", + }, nil) + + defer cleanup(c, "assistants", testScope, name, "1.0.0") + + c.Push("assistants", testScope, name, "1.0.0", zipData) + + _, err := c.DeleteTag("assistants", testScope, name, "nonexistent") + if err == nil { + t.Fatal("expected error deleting non-existent tag") + } + + // Cannot delete latest tag + _, err = c.DeleteTag("assistants", testScope, name, "latest") + if err == nil { + t.Fatal("expected error deleting latest tag") + } + + c.DeleteVersion("assistants", testScope, name, "1.0.0") +} + +// --- Push / Delete error responses --- + +func TestPushDuplicateVersion(t *testing.T) { + c := newClient() + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: "dup-push", Version: "1.0.0", + }, nil) + + defer cleanup(c, "assistants", testScope, "dup-push", "1.0.0") + + _, err := c.Push("assistants", testScope, "dup-push", "1.0.0", zipData) + if err != nil { + t.Fatalf("first push failed: %v", err) + } + + _, err = c.Push("assistants", testScope, "dup-push", "1.0.0", zipData) + if err == nil { + t.Fatal("expected error on duplicate push") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 409 { + t.Errorf("expected 409, got %d", apiErr.StatusCode) + } + + c.DeleteVersion("assistants", testScope, "dup-push", "1.0.0") +} + +func TestDeleteNonExistentVersion(t *testing.T) { + c := newClient() + _, err := c.DeleteVersion("assistants", testScope, "never-existed", "9.9.9") + if err == nil { + t.Fatal("expected error deleting non-existent version") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestSetTagNonExistentPackage(t *testing.T) { + c := newClient() + _, err := c.SetTag("assistants", testScope, "no-such-pkg", "beta", "1.0.0") + if err == nil { + t.Fatal("expected error setting tag on non-existent package") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestListWithQueryFilter(t *testing.T) { + c := newClient() + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "mcp", Scope: testScope, Name: "list-query-mcp", Version: "1.0.0", + Description: "filterable mcp tool", + }, nil) + + defer cleanup(c, "mcps", testScope, "list-query-mcp", "1.0.0") + + c.Push("mcps", testScope, "list-query-mcp", "1.0.0", zipData) + + list, err := c.List("mcps", testScope, "filterable", 1, 10) + if err != nil { + t.Fatalf("List with scope+query failed: %v", err) + } + if list.Total < 1 { + t.Errorf("expected at least 1 result, got %d", list.Total) + } + + c.DeleteVersion("mcps", testScope, "list-query-mcp", "1.0.0") +} + +// --- Release type CRUD --- + +func TestReleaseCRUD(t *testing.T) { + c := newClient() + pkgType := "releases" + name := "test-release" + + zipData, err := testdata.BuildZip(&testdata.Manifest{ + Type: "release", + Scope: testScope, + Name: name, + Version: "1.0.0", + Description: "Test release binary placeholder", + }, map[string]string{ + "bin/yao": "#!/bin/sh\necho hello", + }) + if err != nil { + t.Fatalf("BuildZip: %v", err) + } + + defer cleanup(c, pkgType, testScope, name, "1.0.0") + + result, err := c.Push(pkgType, testScope, name, "1.0.0", zipData) + if err != nil { + t.Fatalf("Push release failed: %v", err) + } + if result.Type != pkgType { + t.Errorf("expected type %s, got %s", pkgType, result.Type) + } + + // Get version detail + ver, err := c.GetVersion(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("GetVersion failed: %v", err) + } + if ver.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", ver.Version) + } + + // Pull + data, _, err := c.Pull(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("Pull failed: %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty pull data") + } + + // Delete + _, err = c.DeleteVersion(pkgType, testScope, name, "1.0.0") + if err != nil { + t.Fatalf("Delete failed: %v", err) + } +} + +// --- httptest-based edge-case coverage --- + +func TestParseErrorNonJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("plain text error")) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("data")) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.Message != "plain text error" { + t.Errorf("expected plain text body in message, got %q", apiErr.Message) + } +} + +func TestInvalidBaseURL(t *testing.T) { + c := registry.New("http://invalid\x7f:8080", registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected error from Push with invalid URL") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected error from SetTag with invalid URL") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected error from DeleteTag with invalid URL") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected error from DeleteVersion with invalid URL") + } +} + +func TestPullNonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"forbidden"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL) + _, _, err := c.Pull("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Fatal("expected error for forbidden pull") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 403 { + t.Errorf("expected 403, got %d", apiErr.StatusCode) + } +} + +func TestSetTagDeleteTagError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"bad request"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + + _, err := c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected error from DeleteTag") + } +} + +func TestDeleteVersionError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"error":"conflict"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + _, err := c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Fatal("expected error") + } +} + +func TestMalformedResponseBody(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == http.MethodPut && r.URL.Path != "/tags/" && callCount <= 2: + w.WriteHeader(http.StatusCreated) + w.Write([]byte("not-json")) + case r.Method == http.MethodPut: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + default: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + } + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected decode error from Push") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected decode error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected decode error from DeleteTag") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected decode error from DeleteVersion") + } +} + +func TestPullReadBodyError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "99999") + w.WriteHeader(http.StatusOK) + w.Write([]byte("short")) + })) + defer srv.Close() + + c := registry.New(srv.URL) + data, _, err := c.Pull("assistants", "@t", "x", "1.0.0") + if err != nil { + t.Logf("got expected error: %v", err) + return + } + if len(data) == 99999 { + t.Error("expected incomplete read") + } +} + +func TestDoTransportError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + closedURL := srv.URL + srv.Close() + + c := registry.New(closedURL, registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected transport error from Push") + } + + _, _, err = c.Pull("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected transport error from Pull") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected transport error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected transport error from DeleteTag") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected transport error from DeleteVersion") + } +} diff --git a/registry/testdata/build.go b/registry/testdata/build.go new file mode 100644 index 00000000..69965fdc --- /dev/null +++ b/registry/testdata/build.go @@ -0,0 +1,70 @@ +// Package testdata provides helpers to build .yao.zip test fixtures in memory. +package testdata + +import ( + "archive/zip" + "bytes" + "encoding/json" +) + +// Manifest mirrors the pkg.yao structure for test fixture construction. +type Manifest struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Dependencies []ManifestDep `json:"dependencies,omitempty"` + Engines map[string]string `json:"engines,omitempty"` + Keywords []string `json:"keywords,omitempty"` + License string `json:"license,omitempty"` + Author *ManifestAuthor `json:"author,omitempty"` +} + +// ManifestDep represents a dependency entry in pkg.yao. +type ManifestDep struct { + Type string `json:"type"` + Scope string `json:"scope"` + Name string `json:"name"` + Version string `json:"version"` +} + +// ManifestAuthor holds author information. +type ManifestAuthor struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` +} + +// BuildZip creates an in-memory .yao.zip with a package/pkg.yao manifest +// and an optional set of extra files (path relative to package/) -> content. +func BuildZip(manifest *Manifest, extraFiles map[string]string) ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + f, err := w.Create("package/pkg.yao") + if err != nil { + return nil, err + } + if _, err := f.Write(data); err != nil { + return nil, err + } + + for name, content := range extraFiles { + f, err := w.Create("package/" + name) + if err != nil { + return nil, err + } + if _, err := f.Write([]byte(content)); err != nil { + return nil, err + } + } + + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +}