Compare commits

...
Author SHA1 Message Date
anandh8x b357a3613c Reject overflowing update versions 2026-06-05 17:25:02 +05:30
anandh8x b203b8e4f0 Tighten Go update check handling 2026-06-05 17:19:54 +05:30
anandh8x 6a86e103e8 Port update check to Go CLI 2026-06-05 17:06:48 +05:30
6 changed files with 800 additions and 0 deletions

No files matched your search

+9
View File
@@ -16,6 +16,7 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/updatecheck"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -34,6 +35,7 @@ type appDeps struct {
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
newMCPStore func() (*mcp.PermissionStore, error)
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
checkUpdate func(context.Context, updatecheck.Options) (updatecheck.Result, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
@@ -90,6 +92,7 @@ func defaultAppDeps() appDeps {
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
return mcp.RegisterTools(ctx, registry, cfg, options)
},
checkUpdate: updatecheck.Check,
prepareWorktree: worktrees.Prepare,
detectVerifyPlan: verify.DetectPlan,
runVerify: verify.Run,
@@ -136,6 +139,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runProviders(args[1:], stdout, stderr, deps)
case "doctor":
return runDoctor(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "search", "find":
return runSearch(args[1:], stdout, stderr, deps)
case "sessions", "session":
@@ -195,6 +200,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.registerMCPTools == nil {
deps.registerMCPTools = defaults.registerMCPTools
}
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.prepareWorktree == nil {
deps.prepareWorktree = defaults.prepareWorktree
}
@@ -297,6 +305,7 @@ Commands:
models List Zero model registry entries
providers Inspect resolved provider profiles
doctor Run backend health checks for config and provider setup
update Check for Zero CLI updates
search Search persisted local Zero session events
find Alias for search
sessions Inspect local Zero session lineage
+2
View File
@@ -208,6 +208,7 @@ func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
{"models"},
{"providers"},
{"doctor"},
{"update"},
{"search"},
{"find"},
{"sessions"},
@@ -262,6 +263,7 @@ func assertHelpOutput(t *testing.T, args []string) {
"models",
"providers",
"doctor",
"update",
"search",
"plugins",
"hooks",
+82
View File
@@ -0,0 +1,82 @@
package cli
import (
"context"
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/updatecheck"
)
type updateOptions struct {
check bool
json bool
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeUpdateHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if !options.check {
return writeAppError(stderr, "Only `zero update --check` is available right now.", exitUsage)
}
result, err := deps.checkUpdate(context.Background(), updatecheck.Options{
CurrentVersion: version,
})
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), 1)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, updatecheck.Format(result)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseUpdateArgs(args []string) (updateOptions, bool, error) {
options := updateOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown update flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected update argument %q", arg)}
}
}
return options, false, nil
}
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [--json]
Checks the latest GitHub release without installing.
Flags:
--check Check the latest release
--json Print the update check result as JSON
-h, --help Show this help
`)
return err
}
+191
View File
@@ -0,0 +1,191 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/updatecheck"
)
func TestRunUpdateRequiresCheck(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
checkCalled := false
exitCode := runWithDeps([]string{"update"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
checkCalled = true
return updatecheck.Result{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if checkCalled {
t.Fatal("update check should not run without --check")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "Only `zero update --check` is available") {
t.Fatalf("expected check-only error, got %q", got)
}
}
func TestRunUpdateRejectsInvalidArguments(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{
name: "unknown flag",
args: []string{"update", "--unknown"},
want: `unknown update flag "--unknown"`,
},
{
name: "unexpected positional argument",
args: []string{"update", "foo"},
want: `unexpected update argument "foo"`,
},
} {
t.Run(test.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(test.args, &stdout, &stderr, appDeps{})
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, test.want) {
t.Fatalf("expected usage error %q, got %q", test.want, got)
}
})
}
}
func TestRunUpdateCheckFormatsHumanOutput(t *testing.T) {
withVersion(t, "0.1.0")
var stdout bytes.Buffer
var stderr bytes.Buffer
var checkOptions updatecheck.Options
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
checkUpdate: func(_ context.Context, options updatecheck.Options) (updatecheck.Result, error) {
checkOptions = options
return updatecheck.Result{
CurrentVersion: "0.1.0",
LatestVersion: "0.2.0",
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
TagName: "v0.2.0",
UpdateAvailable: true,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if checkOptions.CurrentVersion != "0.1.0" {
t.Fatalf("CurrentVersion = %q, want injected CLI version", checkOptions.CurrentVersion)
}
if got := stdout.String(); !strings.Contains(got, "Update available: 0.1.0 -> 0.2.0") {
t.Fatalf("expected update output, got %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunUpdateCheckPrintsJSON(t *testing.T) {
withVersion(t, "0.1.0")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--json"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
return updatecheck.Result{
CurrentVersion: "0.1.0",
LatestVersion: "0.1.0",
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.1.0",
TagName: "v0.1.0",
UpdateAvailable: false,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload updatecheck.Result
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse JSON output %q: %v", stdout.String(), err)
}
if payload.UpdateAvailable {
t.Fatalf("expected updateAvailable=false, got %#v", payload)
}
if payload.CurrentVersion != "0.1.0" || payload.LatestVersion != "0.1.0" {
t.Fatalf("unexpected JSON payload: %#v", payload)
}
}
func TestRunUpdateCheckReportsErrors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
return updatecheck.Result{}, errors.New("network down")
},
})
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "Could not check for updates: network down") {
t.Fatalf("expected update error, got %q", got)
}
}
func TestRunUpdateHelp(t *testing.T) {
for _, args := range [][]string{
{"update", "--help"},
{"update", "-h"},
{"update", "help"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "zero update --check") {
t.Fatalf("expected update help, got %q", stdout.String())
}
})
}
}
func withVersion(t *testing.T, value string) {
t.Helper()
previous := version
version = value
t.Cleanup(func() {
version = previous
})
}
+265
View File
@@ -0,0 +1,265 @@
package updatecheck
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
const (
DefaultRepository = "Gitlawb/zero"
DefaultUpdateCheckTimeout = 5 * time.Second
)
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
type Options struct {
CurrentVersion string
Endpoint string
Repository string
Timeout time.Duration
Client HTTPClient
}
type Result struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseURL string `json:"releaseUrl"`
TagName string `json:"tagName"`
UpdateAvailable bool `json:"updateAvailable"`
}
type endpointResolution struct {
URL string
Repository string
}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
var versionTagPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$`)
var repositorySlugPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
func ReleaseEndpoint(repository string) string {
return "https://api.github.com/repos/" + repository + "/releases/latest"
}
func ResolveReleaseEndpoint(endpointOrRepository string, repository string) (string, error) {
resolved, err := resolveReleaseEndpoint(endpointOrRepository, repository)
if err != nil {
return "", err
}
return resolved.URL, nil
}
func NormalizeVersionTag(version string) (string, error) {
trimmed := strings.TrimSpace(version)
matches := versionTagPattern.FindStringSubmatch(trimmed)
if matches == nil {
return "", fmt.Errorf("invalid semantic version: %s", version)
}
major, err := parseSemverComponent(matches[1], version)
if err != nil {
return "", err
}
minor, err := parseSemverComponent(matches[2], version)
if err != nil {
return "", err
}
patch, err := parseSemverComponent(matches[3], version)
if err != nil {
return "", err
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
}
func CompareSemver(left string, right string) (int, error) {
leftParts, err := parseSemver(left)
if err != nil {
return 0, err
}
rightParts, err := parseSemver(right)
if err != nil {
return 0, err
}
for index := range leftParts {
if leftParts[index] > rightParts[index] {
return 1, nil
}
if leftParts[index] < rightParts[index] {
return -1, nil
}
}
return 0, nil
}
func Check(ctx context.Context, options Options) (Result, error) {
currentVersion, err := NormalizeVersionTag(options.CurrentVersion)
if err != nil {
return Result{}, err
}
repository := strings.TrimSpace(options.Repository)
if repository == "" {
repository = DefaultRepository
}
endpointValue := options.Endpoint
if strings.TrimSpace(endpointValue) == "" {
endpointValue = os.Getenv("ZERO_UPDATE_RELEASE_URL")
}
resolvedEndpoint, err := resolveReleaseEndpoint(endpointValue, repository)
if err != nil {
return Result{}, err
}
timeout := options.Timeout
if timeout == 0 {
timeout = DefaultUpdateCheckTimeout
}
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resolvedEndpoint.URL, nil)
if err != nil {
return Result{}, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", "zero/"+currentVersion)
client := options.Client
if client == nil {
client = http.DefaultClient
}
response, err := client.Do(request)
if err != nil {
return Result{}, err
}
if response.StatusCode < 200 || response.StatusCode > 299 {
status := response.Status
if strings.TrimSpace(status) == "" {
status = strconv.Itoa(response.StatusCode)
}
if err := response.Body.Close(); err != nil {
return Result{}, fmt.Errorf("close release response body: %w", err)
}
return Result{}, fmt.Errorf("GitHub release check failed (%s)", status)
}
body, readErr := io.ReadAll(response.Body)
closeErr := response.Body.Close()
if readErr != nil {
return Result{}, readErr
}
if closeErr != nil {
return Result{}, fmt.Errorf("close release response body: %w", closeErr)
}
var release githubReleaseResponse
if err := json.Unmarshal(body, &release); err != nil {
return Result{}, err
}
if strings.TrimSpace(release.TagName) == "" {
return Result{}, errors.New("GitHub release response did not include a tag_name")
}
latestVersion, err := NormalizeVersionTag(release.TagName)
if err != nil {
return Result{}, err
}
comparison, err := CompareSemver(latestVersion, currentVersion)
if err != nil {
return Result{}, err
}
releaseURL := strings.TrimSpace(release.HTMLURL)
if releaseURL == "" {
releaseURL = fmt.Sprintf("https://github.com/%s/releases/tag/%s", resolvedEndpoint.Repository, release.TagName)
}
return Result{
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseURL: releaseURL,
TagName: release.TagName,
UpdateAvailable: comparison > 0,
}, nil
}
func Format(result Result) string {
if result.UpdateAvailable {
return strings.Join([]string{
fmt.Sprintf("[zero] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion),
"Release: " + result.ReleaseURL,
"Download the matching release asset for your platform, then replace the current zero binary.",
}, "\n")
}
return strings.Join([]string{
fmt.Sprintf("[zero] up to date (%s)", result.CurrentVersion),
"Latest release: " + result.ReleaseURL,
}, "\n")
}
func resolveReleaseEndpoint(endpointOrRepository string, repository string) (endpointResolution, error) {
value := strings.TrimSpace(endpointOrRepository)
if value == "" {
return endpointResolution{URL: ReleaseEndpoint(repository), Repository: repository}, nil
}
if repositorySlugPattern.MatchString(value) {
return endpointResolution{URL: ReleaseEndpoint(value), Repository: value}, nil
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return endpointResolution{}, fmt.Errorf("invalid update endpoint %q. Use a full URL or an owner/repo slug like %s", value, repository)
}
return endpointResolution{URL: value, Repository: repository}, nil
}
func parseSemver(version string) ([3]uint64, error) {
normalized, err := NormalizeVersionTag(version)
if err != nil {
return [3]uint64{}, err
}
parts := strings.Split(normalized, ".")
parsed := [3]uint64{}
for index, part := range parts {
value, err := parseSemverComponent(part, version)
if err != nil {
return [3]uint64{}, err
}
parsed[index] = value
}
return parsed, nil
}
func parseSemverComponent(component string, originalVersion string) (uint64, error) {
value, err := strconv.ParseUint(component, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid semantic version: %s", originalVersion)
}
return value, nil
}
+251
View File
@@ -0,0 +1,251 @@
package updatecheck
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
type roundTripClient struct {
requests []*http.Request
response *http.Response
err error
}
func (client *roundTripClient) Do(request *http.Request) (*http.Response, error) {
client.requests = append(client.requests, request)
if client.err != nil {
return nil, client.err
}
return client.response, nil
}
func releaseResponse(statusCode int, status string, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Status: status,
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestNormalizeVersionTag(t *testing.T) {
tests := map[string]string{
"v0.2.0": "0.2.0",
"0.2.0": "0.2.0",
"v1.2.3+build.4": "1.2.3",
"v01.002.0003-a": "1.2.3",
}
for input, want := range tests {
got, err := NormalizeVersionTag(input)
if err != nil {
t.Fatalf("NormalizeVersionTag(%q) returned error: %v", input, err)
}
if got != want {
t.Fatalf("NormalizeVersionTag(%q) = %q, want %q", input, got, want)
}
}
}
func TestNormalizeVersionTagRejectsOverflowingComponents(t *testing.T) {
_, err := NormalizeVersionTag("v999999999999999999999999999999999999.0.0")
if err == nil || !strings.Contains(err.Error(), "invalid semantic version") {
t.Fatalf("expected overflow to be rejected as an invalid semantic version, got %v", err)
}
}
func TestCompareSemver(t *testing.T) {
assertComparison(t, "0.2.0", "0.1.9", 1)
assertComparison(t, "1.0.0", "0.99.99", 1)
assertComparison(t, "0.1.1", "0.1.2", -1)
assertComparison(t, "v0.1.0", "0.1.0", 0)
}
func TestCheckReportsAvailableUpdate(t *testing.T) {
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{
"tag_name": "v0.2.0",
"html_url": "https://github.com/Gitlawb/zero/releases/tag/v0.2.0"
}`)}
result, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Client: client,
Timeout: -1,
})
if err != nil {
t.Fatalf("Check returned error: %v", err)
}
if result.CurrentVersion != "0.1.0" || result.LatestVersion != "0.2.0" || result.TagName != "v0.2.0" {
t.Fatalf("unexpected result versions: %#v", result)
}
if result.ReleaseURL != "https://github.com/Gitlawb/zero/releases/tag/v0.2.0" {
t.Fatalf("ReleaseURL = %q", result.ReleaseURL)
}
if !result.UpdateAvailable {
t.Fatal("expected update to be available")
}
if len(client.requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(client.requests))
}
request := client.requests[0]
if request.URL.String() != ReleaseEndpoint(DefaultRepository) {
t.Fatalf("request URL = %q, want default release endpoint", request.URL.String())
}
if got := request.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Fatalf("Accept = %q", got)
}
if got := request.Header.Get("User-Agent"); got != "zero/0.1.0" {
t.Fatalf("User-Agent = %q", got)
}
}
func TestCheckReportsUpToDate(t *testing.T) {
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{
"tag_name": "v0.2.0",
"html_url": "https://github.com/Gitlawb/zero/releases/tag/v0.2.0"
}`)}
result, err := Check(context.Background(), Options{
CurrentVersion: "0.2.0",
Client: client,
Timeout: -1,
})
if err != nil {
t.Fatalf("Check returned error: %v", err)
}
if result.UpdateAvailable {
t.Fatal("expected up-to-date result")
}
}
func TestCheckThrowsOnMalformedReleasePayloads(t *testing.T) {
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"name":"Zero 0.2.0"}`)}
_, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Client: client,
Timeout: -1,
})
if err == nil || !strings.Contains(err.Error(), "tag_name") {
t.Fatalf("expected tag_name error, got %v", err)
}
}
func TestCheckAppliesTimeoutContext(t *testing.T) {
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)}
_, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Client: client,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("Check returned error: %v", err)
}
if len(client.requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(client.requests))
}
if _, ok := client.requests[0].Context().Deadline(); !ok {
t.Fatal("expected request context to have a timeout deadline")
}
}
func TestCheckResolvesEndpointPrecedenceAndRepositorySlugs(t *testing.T) {
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)}
t.Setenv("ZERO_UPDATE_RELEASE_URL", "Gitlawb/env-zero")
_, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Endpoint: "Gitlawb/option-zero",
Client: client,
Timeout: -1,
})
if err != nil {
t.Fatalf("Check with endpoint option returned error: %v", err)
}
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/option-zero") {
t.Fatalf("endpoint option URL = %q", got)
}
client.response = releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)
_, err = Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Repository: "Gitlawb/repo-zero",
Client: client,
Timeout: -1,
})
if err != nil {
t.Fatalf("Check with env endpoint returned error: %v", err)
}
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/env-zero") {
t.Fatalf("env endpoint URL = %q", got)
}
t.Setenv("ZERO_UPDATE_RELEASE_URL", "")
client.response = releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)
_, err = Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Repository: "Gitlawb/repo-zero",
Client: client,
Timeout: -1,
})
if err != nil {
t.Fatalf("Check with default repository returned error: %v", err)
}
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/repo-zero") {
t.Fatalf("repository endpoint URL = %q", got)
}
}
func TestResolveReleaseEndpointAcceptsFullURLsAndRejectsInvalidValues(t *testing.T) {
got, err := ResolveReleaseEndpoint("https://example.test/latest", "Gitlawb/zero")
if err != nil {
t.Fatalf("ResolveReleaseEndpoint full URL returned error: %v", err)
}
if got != "https://example.test/latest" {
t.Fatalf("full URL = %q", got)
}
got, err = ResolveReleaseEndpoint("Gitlawb/zero", "Fallback/repo")
if err != nil {
t.Fatalf("ResolveReleaseEndpoint slug returned error: %v", err)
}
if got != ReleaseEndpoint("Gitlawb/zero") {
t.Fatalf("slug URL = %q", got)
}
_, err = ResolveReleaseEndpoint("not-a-url", "Gitlawb/zero")
if err == nil || !strings.Contains(err.Error(), "invalid update endpoint") {
t.Fatalf("expected invalid endpoint error, got %v", err)
}
}
func TestFormat(t *testing.T) {
output := Format(Result{
CurrentVersion: "0.1.0",
LatestVersion: "0.2.0",
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
TagName: "v0.2.0",
UpdateAvailable: true,
})
if !strings.Contains(output, "Update available: 0.1.0 -> 0.2.0") {
t.Fatalf("expected update text, got %q", output)
}
}
func assertComparison(t *testing.T, left string, right string, want int) {
t.Helper()
got, err := CompareSemver(left, right)
if err != nil {
t.Fatalf("CompareSemver(%q, %q) returned error: %v", left, right, err)
}
if got != want {
t.Fatalf("CompareSemver(%q, %q) = %d, want %d", left, right, got, want)
}
}