diff --git a/internal/registry/catalog_test.go b/internal/registry/catalog_test.go new file mode 100644 index 0000000..e61c727 --- /dev/null +++ b/internal/registry/catalog_test.go @@ -0,0 +1,190 @@ +package registry + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aviorstudio/termcade/sdk" +) + +// The catalog is paged, so browsing the marketplace is several requests. The +// failure this guards against is quiet: a client that stops early shows a +// short marketplace and nothing anywhere says a game is missing. + +// pagedCatalog serves n games, limit per page, in the registry's envelope. +func pagedCatalog(t *testing.T, n, perPage int) (*httptest.Server, *int) { + t.Helper() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + start := 0 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { + fmt.Sscanf(cursor, "at-%d", &start) + } + page := struct { + Games []Game `json:"games"` + Next string `json:"next,omitempty"` + }{} + for i := start; i < n && i < start+perPage; i++ { + page.Games = append(page.Games, Game{ + ID: fmt.Sprintf("aviorstudio/game-%03d", i), Name: "Game", HasPackage: true, + }) + } + if start+perPage < n { + page.Next = fmt.Sprintf("at-%d", start+perPage) + } + json.NewEncoder(w).Encode(page) + })) + t.Cleanup(server.Close) + return server, &requests +} + +func TestGamesFollowsEveryCursor(t *testing.T) { + server, requests := pagedCatalog(t, 47, 10) + + games, err := New(server.URL, "").Games() + if err != nil { + t.Fatalf("Games: %v", err) + } + if len(games) != 47 { + t.Fatalf("got %d games, want all 47 — a page was dropped", len(games)) + } + if *requests != 5 { + t.Errorf("made %d requests for 5 pages", *requests) + } + seen := map[string]bool{} + for _, g := range games { + if seen[g.ID] { + t.Fatalf("%s came back twice", g.ID) + } + seen[g.ID] = true + } +} + +// The end is an absent cursor. A page that happens to be empty in the middle +// of a walk — which the registry's compatibility filter can produce — is not +// the end, and stopping there loses everything after it. +func TestAnEmptyPageIsNotTheEnd(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("cursor") { + case "": + fmt.Fprint(w, `{"games":[],"next":"second"}`) + case "second": + fmt.Fprint(w, `{"games":[{"id":"aviorstudio/tetris","has_package":true}]}`) + } + })) + defer server.Close() + + games, err := New(server.URL, "").Games() + if err != nil { + t.Fatalf("Games: %v", err) + } + if len(games) != 1 || games[0].ID != "aviorstudio/tetris" { + t.Fatalf("an empty first page ended the walk: %v", games) + } +} + +// A registry that never stops handing out cursors must not hang the arcade. +func TestGamesStopsWalkingEventually(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"games":[{"id":"a/b","has_package":true}],"next":"forever"}`) + })) + defer server.Close() + + games, err := New(server.URL, "").Games() + if err != nil { + t.Fatalf("Games: %v", err) + } + if len(games) != maxCatalogPages { + t.Errorf("walked %d pages, want it bounded at %d", len(games), maxCatalogPages) + } +} + +// Browsing asks for what this arcade can run. A marketplace full of entries +// that refuse to install is worse than a shorter one. +func TestGamesAsksForRunnableReleases(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + fmt.Fprint(w, `{"games":[]}`) + })) + defer server.Close() + + if _, err := New(server.URL, "").Games(); err != nil { + t.Fatal(err) + } + if want := fmt.Sprintf("abi=%d", sdk.ABIVersion); !strings.Contains(query, want) { + t.Errorf("catalog request %q does not carry %s", query, want) + } +} + +func TestCatalogQueryEncodesOnlyWhatWasSet(t *testing.T) { + if got := (CatalogQuery{}).values().Encode(); got != "" { + t.Errorf("an empty query encoded as %q", got) + } + got := CatalogQuery{Cursor: "c", Limit: 10, Search: "tet", ABI: 1, Sort: "newest"}.values().Encode() + for _, want := range []string{"cursor=c", "limit=10", "q=tet", "abi=1", "sort=newest"} { + if !strings.Contains(got, want) { + t.Errorf("query %q is missing %s", got, want) + } + } +} + +// ------------------------------------------------------------- contract -- + +// The examples in contract/ are copied from aviorstudio/termcade-be, which +// generates them from the API itself. Decoding them here is what stops this +// client's idea of the wire format drifting from the server's: a renamed field +// becomes a zero value, and a zero value looks exactly like a game with no +// release rather than like a bug. +func contractExample(t *testing.T, name string, out any) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("contract", name)) + if err != nil { + t.Fatalf("reading the contract example: %v", err) + } + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("%s does not decode into %T: %v", name, out, err) + } +} + +func TestCatalogExampleDecodes(t *testing.T) { + var page CatalogPage + contractExample(t, "catalog.json", &page) + + if len(page.Games) == 0 { + t.Fatal("the recorded catalog page has no games") + } + // The field the arcade has to learn to follow. + if page.Next == "" { + t.Error("the recorded page carries no cursor, so following one is untested") + } + + game := page.Games[0] + if !strings.Contains(game.ID, "/") { + t.Errorf("id %q is not namespaced — the handle expansion is not reaching the wire", game.ID) + } + if game.Name == "" || !game.HasPackage { + t.Errorf("catalog row decoded thin: %+v", game) + } + if game.Version == "" || game.SHA256 == "" { + t.Errorf("release fields did not decode: %+v", game) + } + if game.CreatedAt == "" || game.ReleasedAt == "" { + t.Errorf("timestamps did not decode: %+v", game) + } +} + +func TestGameExampleDecodes(t *testing.T) { + var game Game + contractExample(t, "game.json", &game) + if !strings.Contains(game.ID, "/") || game.Name == "" { + t.Errorf("game example decoded thin: %+v", game) + } +} diff --git a/internal/registry/client.go b/internal/registry/client.go index 7727449..b74f98c 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -91,6 +91,10 @@ type Game struct { Height int `json:"height"` HasPackage bool `json:"has_package"` SHA256 string `json:"sha256"` + // RFC 3339. When the game entered the catalog, and when its newest release + // was published; the second is absent on a game that has none. + CreatedAt string `json:"created_at,omitempty"` + ReleasedAt string `json:"released_at,omitempty"` } // Resolved is which release to install and what it must hash to. The registry @@ -205,9 +209,88 @@ func (c *Client) do(method, path string, body, out any) error { return json.NewDecoder(resp.Body).Decode(out) } +// CatalogPage is one page of the marketplace and the cursor that continues it. +// +// Next empty is the end. A SHORT PAGE IS NOT: the registry applies its +// compatibility filter after reading a page, so a page can hold fewer games +// than asked for while the catalog continues. Stopping on a short page shows a +// truncated marketplace with nothing to say anything is missing. +type CatalogPage struct { + Games []Game `json:"games"` + Next string `json:"next,omitempty"` +} + +// CatalogQuery narrows a catalog request. The zero value asks for the first +// page of everything. +type CatalogQuery struct { + Cursor string + // Limit is 1-200; zero lets the registry choose. + Limit int + // Search matches a game's name, id or description. + Search string + // ABI restricts to games this arcade can run. Set by Games(); the registry + // treats zero as "do not filter". + ABI int + Sort string // "slug" (default) or "newest" +} + +func (q CatalogQuery) values() url.Values { + v := url.Values{} + if q.Cursor != "" { + v.Set("cursor", q.Cursor) + } + if q.Limit > 0 { + v.Set("limit", strconv.Itoa(q.Limit)) + } + if q.Search != "" { + v.Set("q", q.Search) + } + if q.ABI > 0 { + v.Set("abi", strconv.Itoa(q.ABI)) + } + if q.Sort != "" { + v.Set("sort", q.Sort) + } + return v +} + +// CatalogPage fetches one page of the marketplace. +func (c *Client) CatalogPage(q CatalogQuery) (CatalogPage, error) { + path := "/v1/games" + if encoded := q.values().Encode(); encoded != "" { + path += "?" + encoded + } + var page CatalogPage + return page, c.do(http.MethodGet, path, nil, &page) +} + +// maxCatalogPages bounds how far Games will walk. The marketplace is a screen +// somebody scrolls, not an index anybody mirrors; at the registry's ceiling of +// 200 a page this is four thousand games, and a limit that is reached returns +// what it has rather than failing — a marketplace showing four thousand beats +// one showing an error. +const maxCatalogPages = 20 + +// Games lists the marketplace, following cursors to the end. +// +// The catalog is paged, so this is several requests rather than one. It asks +// for games this arcade can run: a marketplace full of entries that refuse to +// install is worse than a shorter one. func (c *Client) Games() ([]Game, error) { - var games []Game - return games, c.do(http.MethodGet, "/v1/games", nil, &games) + var all []Game + query := CatalogQuery{ABI: sdk.ABIVersion} + for range maxCatalogPages { + page, err := c.CatalogPage(query) + if err != nil { + return nil, err + } + all = append(all, page.Games...) + if page.Next == "" { + return all, nil + } + query.Cursor = page.Next + } + return all, nil } // Resolve asks the registry which release to install: the newest one this diff --git a/internal/registry/contract/README.md b/internal/registry/contract/README.md new file mode 100644 index 0000000..2bde48c --- /dev/null +++ b/internal/registry/contract/README.md @@ -0,0 +1,16 @@ +# contract + +Recorded examples of the registry's response models, copied from +[termcade-be](https://github.com/aviorstudio/termcade-be)'s `contract/`, which +generates them from the API itself. + +`catalog_test.go` decodes them into this package's types. That is the only +thing standing between the arcade's idea of the wire format and the server's: +a renamed field decodes as a zero value, and a zero value looks exactly like a +game with no release rather than like a bug. + +**Copied, not shared.** A Go module cannot read files out of a sibling +repository, and vendoring the API to get four JSON files would be a dependency +on the whole backend. The cost is that these can go stale — so when the API +changes a response model it regenerates its own copy, and updating this one is +part of the change that follows here. diff --git a/internal/registry/contract/activity.json b/internal/registry/contract/activity.json new file mode 100644 index 0000000..3bd6e63 --- /dev/null +++ b/internal/registry/contract/activity.json @@ -0,0 +1,10 @@ +[ + { + "id": "aviorstudio/brickough", + "personal_best": 4242, + "plays": 1, + "last_played": "2026-08-01T10:00:00Z", + "last_completed": "2026-08-01T10:00:00Z", + "last_version": "1.2.0" + } +] diff --git a/internal/registry/contract/catalog.json b/internal/registry/contract/catalog.json new file mode 100644 index 0000000..e3ecfc7 --- /dev/null +++ b/internal/registry/contract/catalog.json @@ -0,0 +1,19 @@ +{ + "games": [ + { + "id": "aviorstudio/brickough", + "name": "Brickough", + "description": "Breakout-style brick breaker", + "repo": "https://github.com/aviorstudio/termcade-games", + "version": "1.2.0", + "abi": 1, + "width": 64, + "height": 40, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "has_package": true, + "created_at": "2026-07-30T09:00:00Z", + "released_at": "2026-08-01T12:00:00Z" + } + ], + "next": "MQBzbHVnAGJyaWNrb3VnaABnLWJyaWNr" +} diff --git a/internal/registry/contract/game.json b/internal/registry/contract/game.json new file mode 100644 index 0000000..871c3ad --- /dev/null +++ b/internal/registry/contract/game.json @@ -0,0 +1,14 @@ +{ + "id": "aviorstudio/brickough", + "name": "Brickough", + "description": "Breakout-style brick breaker", + "repo": "https://github.com/aviorstudio/termcade-games", + "version": "1.2.0", + "abi": 1, + "width": 64, + "height": 40, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "has_package": true, + "created_at": "2026-07-30T09:00:00Z", + "released_at": "2026-08-01T12:00:00Z" +} diff --git a/internal/registry/contract/library.json b/internal/registry/contract/library.json new file mode 100644 index 0000000..5519441 --- /dev/null +++ b/internal/registry/contract/library.json @@ -0,0 +1,24 @@ +[ + { + "id": "aviorstudio/brickough", + "name": "Brickough", + "description": "Breakout-style brick breaker", + "repo": "https://github.com/aviorstudio/termcade-games", + "version": "1.2.0", + "abi": 1, + "width": 64, + "height": 40, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "has_package": true, + "created_at": "2026-07-30T09:00:00Z", + "released_at": "2026-08-01T12:00:00Z", + "activity": { + "id": "aviorstudio/brickough", + "personal_best": 4242, + "plays": 1, + "last_played": "2026-08-01T10:00:00Z", + "last_completed": "2026-08-01T10:00:00Z", + "last_version": "1.2.0" + } + } +]