Files
marketplace/cli/cmd/release/release_test.go
T
naudachu a685f23bc0 test: anchor the release fixtures at both ends of the walk
`harmless` pointed CLAUDE_PROJECT_DIR at an empty temp directory and left the
working directory where `go test` put it. The walk has two anchors, so the
second one carried the fixture back up into this repository, which has a
`.kettle/` marker of its own — gitignored, and therefore present in every
developer's checkout and in no clone. The run resolved `login: noodles` from it
and then failed against the fixture's empty config home.

TestRunPublishesFromTheEnvironmentWithNoProjectInSight is precisely the test
that is about resolving nothing at all, and it passed only while nobody had run
`kettle init` here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:45:51 +05:00

687 lines
22 KiB
Go

package main
// The publisher is tested against httptest, never against an instance: a test
// that needs a server somewhere is a test nobody runs, and this is the one tool
// in the tree whose mistakes are visible to everybody who downloads a binary.
//
// Every fixture puts BOTH anchors of the walk — CLAUDE_PROJECT_DIR and the
// working directory — in an empty temp directory, so there is no `.kettle/`
// marker anywhere on the way up. That is the state a fresh clone is in and the
// whole reason this tool resolves its configuration the way it does. It also
// points KETTLE_CONFIG_HOME at another temp directory, so a run can neither
// read nor overwrite the developer's own tokens.
//
// THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a
// request: the SDK asks the instance what it is before it hands a client back,
// and a fake that did not answer is a fake nothing can be built against.
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
// modernGitea is what the fake says it is: new enough for every route this
// tool asks for.
const modernGitea = "1.26.1"
// harmless points every fixture away from the machine it runs on.
func harmless(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("CLAUDE_PROJECT_DIR", dir)
// The walk has two anchors and CLAUDE_PROJECT_DIR is only the first: with
// the working directory left where `go test` put it, a run inside a tree
// that has a marker resolves that tree's config. This repository has one —
// it is gitignored, so it is present in a developer's checkout and in no
// clone — and a fixture about resolving nothing must not find it.
t.Chdir(dir)
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
// An exported KETTLE_URL in the developer's shell would otherwise decide
// what a test resolved to, and one of these tests is about resolving
// nothing at all.
for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} {
t.Setenv(key, "")
}
}
func configFor(url string) *config.Resolved {
return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}
}
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type fake struct {
mu sync.Mutex
base string
version string
nextID int64
releases []*sdk.Release
assets map[int64][]*sdk.Attachment
content map[int64][]byte
requests []string
// hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft,
// which is what an instance does when the tag itself is not in git yet.
hideDraftsFromTheTagRoute bool
}
func newFake(t *testing.T) *fake {
t.Helper()
f := &fake{
version: modernGitea,
assets: map[int64][]*sdk.Attachment{},
content: map[int64][]byte{},
}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
f.base = srv.URL
return f
}
func (f *fake) url() string { return f.base }
func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v1/version" {
writeJSON(w, map[string]string{"version": f.version})
return
}
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases")
if !ok {
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path)
return
}
var parts []string
if rest = strings.Trim(rest, "/"); rest != "" {
parts = strings.Split(rest, "/")
}
switch {
case len(parts) == 0 && r.Method == http.MethodGet:
f.list(w, r)
case len(parts) == 0 && r.Method == http.MethodPost:
f.create(w, r)
case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet:
f.byTag(w, parts[1])
case len(parts) == 1 && r.Method == http.MethodPatch:
f.edit(w, r, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet:
f.listAssets(w, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost:
f.addAsset(w, r, parts[0])
case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete:
f.dropAsset(w, parts[0], parts[2])
default:
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path)
}
}
func (f *fake) list(w http.ResponseWriter, r *http.Request) {
if page := r.URL.Query().Get("page"); page != "" && page != "1" {
writeJSON(w, []*sdk.Release{})
return
}
writeJSON(w, f.releases)
}
func (f *fake) create(w http.ResponseWriter, r *http.Request) {
var opt sdk.CreateReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
f.nextID++
rel := &sdk.Release{
ID: f.nextID,
TagName: opt.TagName,
Target: opt.Target,
Title: opt.Title,
Note: opt.Note,
IsDraft: opt.IsDraft,
IsPrerelease: opt.IsPrerelease,
HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName,
}
f.releases = append(f.releases, rel)
w.WriteHeader(http.StatusCreated)
writeJSON(w, rel)
}
func (f *fake) byTag(w http.ResponseWriter, tag string) {
for _, rel := range f.releases {
if rel.TagName != tag {
continue
}
if rel.IsDraft && f.hideDraftsFromTheTagRoute {
break
}
writeJSON(w, rel)
return
}
f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found")
}
func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
var opt sdk.EditReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
// Gitea's own semantics: an empty string leaves the field alone.
if opt.Title != "" {
rel.Title = opt.Title
}
if opt.Note != "" {
rel.Note = opt.Note
}
if opt.IsDraft != nil {
rel.IsDraft = *opt.IsDraft
}
if opt.IsPrerelease != nil {
rel.IsPrerelease = *opt.IsPrerelease
}
writeJSON(w, rel)
}
func (f *fake) listAssets(w http.ResponseWriter, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
got := f.assets[rel.ID]
if got == nil {
got = []*sdk.Attachment{}
}
writeJSON(w, got)
}
func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
file, header, err := r.FormFile("attachment")
if err != nil {
f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error())
return
}
defer file.Close()
raw, err := io.ReadAll(file)
if err != nil {
f.refuse(w, http.StatusInternalServerError, err.Error())
return
}
f.nextID++
a := &sdk.Attachment{
ID: f.nextID,
Name: header.Filename,
Size: int64(len(raw)),
DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename,
}
f.assets[rel.ID] = append(f.assets[rel.ID], a)
f.content[a.ID] = raw
w.WriteHeader(http.StatusCreated)
writeJSON(w, a)
}
func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
want, _ := strconv.ParseInt(asset, 10, 64)
kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID]))
for _, a := range f.assets[rel.ID] {
if a.ID != want {
kept = append(kept, a)
}
}
f.assets[rel.ID] = kept
w.WriteHeader(http.StatusNoContent)
}
func (f *fake) release(id string) *sdk.Release {
want, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil
}
for _, rel := range f.releases {
if rel.ID == want {
return rel
}
}
return nil
}
func (f *fake) refuse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
}
// assetNamed is what the tracker holds under this name, for the test that says
// a replacement leaves exactly one.
func (f *fake) assetNamed(name string) []*sdk.Attachment {
f.mu.Lock()
defer f.mu.Unlock()
var out []*sdk.Attachment
for _, batch := range f.assets {
for _, a := range batch {
if a.Name == name {
out = append(out, a)
}
}
}
return out
}
func (f *fake) bytesOf(a *sdk.Attachment) string {
f.mu.Lock()
defer f.mu.Unlock()
return string(f.content[a.ID])
}
func (f *fake) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string{}, f.requests...)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// --------------------------------------------------------------------------
// the tests
// --------------------------------------------------------------------------
// The ordinary case: a tag nobody has published yet, and two files that belong
// on it.
func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly")
sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n")
got, err := publish(configFor(f.url()), spec{
Tag: "v1.2.3",
Notes: "what changed\n",
Files: []string{binary, sums},
})
if err != nil {
t.Fatalf("publish: %v", err)
}
if got.State != "created" {
t.Errorf("state is %q, want created", got.State)
}
if len(f.releases) != 1 {
t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases))
}
rel := f.releases[0]
if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" {
t.Errorf("the release is %+v", rel)
}
// Gitea refuses a release with no title, so the tag stands in for one.
if rel.Title != "v1.2.3" {
t.Errorf("title is %q, want the tag", rel.Title)
}
if len(got.Assets) != 2 {
t.Fatalf("got %d asset(s), want 2", len(got.Assets))
}
for name, want := range map[string]string{
"kettle_v1.2.3_linux_amd64": "a binary, honestly",
"SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n",
} {
held := f.assetNamed(name)
if len(held) != 1 {
t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name)
}
if body := f.bytesOf(held[0]); body != want {
t.Errorf("%s arrived as %q, want %q", name, body, want)
}
}
// The receipt is the whole user experience of a tool nobody watches run.
var out strings.Builder
got.print(&out)
for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL,
"kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, out.String())
}
}
}
// A re-run is not a failure and not a second release. It is also not a no-op
// when something changed: a retry that fixed the notes has to leave the fixed
// notes behind.
func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one")
first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a re-run left %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused — nothing had changed", again.State)
}
if again.Release.ID != first.Release.ID {
t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID)
}
if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 {
t.Errorf("the tracker holds %d copies of the one asset", len(held))
}
// And the corrected notes actually land.
fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}})
if err != nil {
t.Fatalf("the third publish: %v", err)
}
if fixed.State != "updated" {
t.Errorf("state is %q, want updated — the notes changed", fixed.State)
}
if f.releases[0].Note != "second go" {
t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note)
}
if len(f.releases) != 1 {
t.Errorf("converging forked the release: %d of them", len(f.releases))
}
}
// Two attachments with one name is the silent failure: the download URL names
// the file, so the second copy is not addressable and nobody notices which one
// people got.
func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build")
if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil {
t.Fatalf("the first publish: %v", err)
}
// Same name, different bytes — a rebuild after a fix, which is exactly when
// somebody re-runs this.
writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build")
got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
held := f.assetNamed("kettle_v3.0.0_linux_arm64")
if len(held) != 1 {
t.Fatalf("the release carries %d attachments of that name, want 1", len(held))
}
if body := f.bytesOf(held[0]); body != "the second build" {
t.Errorf("the asset is %q — the replacement did not take", body)
}
if len(got.Assets) != 1 || !got.Assets[0].Replaced {
t.Errorf("the receipt does not report a replacement: %+v", got.Assets)
}
var out strings.Builder
got.print(&out)
if !strings.Contains(out.String(), "replaced") {
t.Errorf("the receipt does not say it replaced anything:\n%s", out.String())
}
}
// A draft has no git tag behind it, so the by-tag route can answer 404 for a
// release that is plainly there. A publish that believed it would file a second
// release every time it was retried.
func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) {
harmless(t)
f := newFake(t)
f.hideDraftsFromTheTagRoute = true
s := spec{Tag: "v4.0.0", Draft: true}
if _, err := publish(configFor(f.url()), s); err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), s)
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a retried draft published %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused", again.State)
}
if !f.releases[0].IsDraft {
t.Error("the release stopped being a draft")
}
}
// A half-filled configuration is refused before anything is dialled, naming the
// variable or the command that supplies what is missing. "401 Unauthorized"
// names nothing anybody can act on.
func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) {
harmless(t)
f := newFake(t)
for _, tc := range []struct {
what string
cfg *config.Resolved
want string
}{
{"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
{"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken},
{"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo},
{"nothing at all", &config.Resolved{}, config.EnvURL},
} {
_, err := publish(tc.cfg, spec{Tag: "v0.0.1"})
if err == nil {
t.Errorf("%s: accepted", tc.what)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err)
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a request went out for a configuration that was refused: %v", calls)
}
}
// End to end through main's own argument handling, with the credentials in the
// environment and no project anywhere on the way up — which is the state a
// clone is in, and the reason this resolves configuration the way it does.
func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me")
notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n")
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
var stdout, stderr strings.Builder
code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary},
&stdout, &stderr)
if code != 0 {
t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" {
t.Fatalf("the tracker holds %+v", f.releases)
}
if !strings.Contains(f.releases[0].Note, "It does the thing.") {
t.Errorf("the notes file did not arrive: %q", f.releases[0].Note)
}
for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, stdout.String())
}
}
// A token in a receipt is a token in a terminal scrollback and a pasted
// bug report.
if strings.Contains(stdout.String()+stderr.String(), "s3cret") {
t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String())
}
}
// Everything a person can get wrong in the arguments is reported before a
// release exists to be half-published.
func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x")
elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64")
if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
for _, tc := range []struct {
what string
argv []string
want string
}{
{"no tag", []string{here}, "--tag is required"},
{"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"},
{"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"},
{"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"},
{"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"},
} {
var stdout, stderr strings.Builder
if code := run(tc.argv, &stdout, &stderr); code != 2 {
t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) {
t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String())
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a refused run still talked to the tracker: %v", calls)
}
if len(f.releases) != 0 {
t.Errorf("a refused run created %d release(s)", len(f.releases))
}
}
// A failure carries the status and what the server said, in the transport's own
// error type, because "500" on its own has never helped anybody.
func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) {
harmless(t)
// A token that is not allowed to write releases is the failure somebody
// will actually meet: reads are fine, the create is refused.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
writeJSON(w, map[string]string{"version": modernGitea})
case r.Method == http.MethodPost:
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`)
case strings.Contains(r.URL.Path, "/releases/tags/"):
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`)
default:
writeJSON(w, []*sdk.Release{})
}
}))
t.Cleanup(srv.Close)
_, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"})
if err == nil {
t.Fatal("a 403 published a release")
}
for _, want := range []string{"403", "write:repository", "v7.0.0"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the failure does not mention %q:\n%v", want, err)
}
}
if strings.Contains(err.Error(), "s3cret") {
t.Errorf("the failure quotes the token:\n%v", err)
}
}
// A tool nobody watches run has to be readable when somebody finally does.
func TestTheReceiptIsAligned(t *testing.T) {
r := &receipt{
Repo: "acme/widgets",
State: "created",
Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"},
Assets: []asset{
{Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"},
{Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true},
},
}
var out strings.Builder
r.print(&out)
// One line for the release, one per asset, the URL, and the summary.
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) != 5 {
t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String())
}
// The URLs line up, which is what makes a column of them scannable.
first := strings.Index(lines[1], "https://")
if second := strings.Index(lines[2], "https://"); first != second {
t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String())
}
if !strings.HasPrefix(lines[2], "replaced") {
t.Errorf("a replaced asset is not called one:\n%s", out.String())
}
// The URL a person opens is on its own line, not buried in a summary.
if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") {
t.Errorf("the release URL is not on its own line:\n%s", out.String())
}
if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) {
t.Errorf("the summary does not read %q:\n%s", want, out.String())
}
}