diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go new file mode 100644 index 0000000..2163de6 --- /dev/null +++ b/internal/cli/resolve.go @@ -0,0 +1,94 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" + "github.com/spf13/cobra" +) + +// resolveOptions mirrors the install flags that affect asset selection. +type resolveOptions struct { + PHPVersion string + ZTS bool + ExtensionOnly bool +} + +// newResolveCmd is a hidden diagnostic command: it detects the host, fetches the +// latest release, and prints the asset it would download — without installing +// anything. Useful for verifying platform detection and asset selection. +func newResolveCmd() *cobra.Command { + opts := &resolveOptions{} + + cmd := &cobra.Command{ + Use: "resolve", + Short: "Print the release asset that would be used for this host (diagnostic)", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runResolve(cmd, opts) + }, + } + + f := cmd.Flags() + f.StringVarP(&opts.PHPVersion, "php", "p", "", "PHP version (default: latest available)") + f.BoolVarP(&opts.ZTS, "zts", "z", false, "thread-safe build") + f.BoolVarP(&opts.ExtensionOnly, "extension-only", "e", false, "resolve the extension instead of the interpreter") + + return cmd +} + +func runResolve(cmd *cobra.Command, opts *resolveOptions) error { + p, err := platform.Detect() + if err != nil { + return err + } + + kind := release.Interpreter + if opts.ExtensionOnly { + kind = release.Extension + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + + client := release.NewClient() + rel, err := client.LatestRelease(ctx) + if err != nil { + return err + } + + series := opts.PHPVersion + if series == "" { + series, err = release.LatestSeries(rel.Assets, kind, opts.ZTS, p.OS, p.Arch) + if err != nil { + return err + } + } + + asset, err := release.SelectAsset(rel.Assets, release.Selector{ + Kind: kind, + Series: series, + ZTS: opts.ZTS, + OS: p.OS, + Arch: p.Arch, + }) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "host: %s\n", p) + fmt.Fprintf(out, "release: %s\n", rel.TagName) + fmt.Fprintf(out, "kind: %s\n", kind) + fmt.Fprintf(out, "php: %s (zts=%t)\n", series, opts.ZTS) + fmt.Fprintf(out, "asset: %s\n", asset.Name) + fmt.Fprintf(out, "url: %s\n", asset.DownloadURL) + if asset.Size > 0 { + fmt.Fprintf(out, "size: %.1f MiB\n", float64(asset.Size)/(1024*1024)) + } + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b5ab37a..c5fe3c0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -44,6 +44,7 @@ func newRootCmd() *cobra.Command { newUpdateCmd(), newUninstallCmd(), newSwitchCmd(), + newResolveCmd(), ) return rootCmd diff --git a/internal/release/naming.go b/internal/release/naming.go new file mode 100644 index 0000000..1b2594c --- /dev/null +++ b/internal/release/naming.go @@ -0,0 +1,237 @@ +package release + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/php-debugger/installer/internal/platform" +) + +// Kind distinguishes the two sorts of release asset the installer cares about. +type Kind int + +const ( + // Interpreter is a self-contained php binary with the debugger compiled in. + Interpreter Kind = iota + // Extension is the debugger loadable extension (.so/.dll). + Extension +) + +func (k Kind) String() string { + if k == Extension { + return "extension" + } + return "interpreter" +} + +// ParsedAsset is the structured interpretation of a release asset filename. +type ParsedAsset struct { + Kind Kind + Series string // PHP series as it appears in the name, e.g. "8.3" + ZTS bool + OS platform.OS + Arch platform.Arch +} + +// Selector describes the asset the caller wants. An empty Series matches any and +// selects the highest available. +type Selector struct { + Kind Kind + Series string + ZTS bool + OS platform.OS + Arch platform.Arch +} + +var seriesRe = regexp.MustCompile(`^\d+\.\d+(?:\.\d+)*$`) + +// ParseAssetName interprets a release asset filename. It is deliberately +// tolerant: rather than matching an exact template, it detects the asset kind +// from the file suffix (.so/.dll = extension; .exe or none = interpreter) and +// extracts the PHP series, threading model, OS and architecture from the +// hyphen-separated tokens. This absorbs the fact that the two kinds use +// different prefixes. It returns ok=false for anything it does not recognize +// (checksums, source archives, …). +// +// Confirmed naming in release 0.1.0: +// - interpreter: php-php{ver}-{nts|ts}-{os}-{arch}[.exe] +// - extension: php-debugger-php{ver}-{nts|ts}-{os}-{arch}.{so|dll} +// +// This function is the single place that encodes the release naming convention; +// adjust it here if the published names change. +func ParseAssetName(name string) (ParsedAsset, bool) { + lower := strings.ToLower(name) + + kind := Interpreter + base := name + switch { + case strings.HasSuffix(lower, ".so"): + kind, base = Extension, name[:len(name)-len(".so")] + case strings.HasSuffix(lower, ".dll"): + kind, base = Extension, name[:len(name)-len(".dll")] + case strings.HasSuffix(lower, ".exe"): + kind, base = Interpreter, name[:len(name)-len(".exe")] + } + + var ( + p ParsedAsset + haveVer, haveThread, haveOS, haveArch bool + ) + p.Kind = kind + + for _, tok := range strings.Split(strings.ToLower(base), "-") { + switch { + case strings.HasPrefix(tok, "php") && seriesRe.MatchString(tok[len("php"):]): + p.Series, haveVer = tok[len("php"):], true + case tok == "ts": + p.ZTS, haveThread = true, true + case tok == "nts": + p.ZTS, haveThread = false, true + case tok == "linux": + p.OS, haveOS = platform.Linux, true + case tok == "macos": + p.OS, haveOS = platform.MacOS, true + case tok == "windows": + p.OS, haveOS = platform.Windows, true + case tok == "x86_64": + p.Arch, haveArch = platform.X8664, true + case tok == "arm64": + p.Arch, haveArch = platform.Arm64, true + } + } + + if !(haveVer && haveThread && haveOS && haveArch) { + return ParsedAsset{}, false + } + return p, true +} + +// SelectAsset returns the asset matching sel. When sel.Series is empty it selects +// the highest available series among the matches. It returns a *NoMatchError +// (listing what is available) if nothing matches. +func SelectAsset(assets []Asset, sel Selector) (Asset, error) { + type candidate struct { + asset Asset + parsed ParsedAsset + } + var matches []candidate + for _, a := range assets { + p, ok := ParseAssetName(a.Name) + if !ok || p.Kind != sel.Kind || p.OS != sel.OS || p.Arch != sel.Arch || p.ZTS != sel.ZTS { + continue + } + if sel.Series != "" && p.Series != sel.Series { + continue + } + matches = append(matches, candidate{a, p}) + } + if len(matches) == 0 { + return Asset{}, &NoMatchError{Selector: sel, Available: availableVariants(assets, sel.Kind)} + } + best := matches[0] + for _, m := range matches[1:] { + if compareSeries(m.parsed.Series, best.parsed.Series) > 0 { + best = m + } + } + return best.asset, nil +} + +// LatestSeries returns the highest PHP series among assets matching the given +// kind, threading, OS and architecture. It errors if none match. +func LatestSeries(assets []Asset, kind Kind, zts bool, osID platform.OS, arch platform.Arch) (string, error) { + var best string + for _, a := range assets { + p, ok := ParseAssetName(a.Name) + if !ok || p.Kind != kind || p.OS != osID || p.Arch != arch || p.ZTS != zts { + continue + } + if best == "" || compareSeries(p.Series, best) > 0 { + best = p.Series + } + } + if best == "" { + return "", &NoMatchError{ + Selector: Selector{Kind: kind, ZTS: zts, OS: osID, Arch: arch}, + Available: availableVariants(assets, kind), + } + } + return best, nil +} + +// compareSeries compares two dotted version strings numerically. Returns -1, 0 +// or 1. Non-numeric fields sort as 0. +func compareSeries(a, b string) int { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var ai, bi int + if i < len(as) { + ai, _ = strconv.Atoi(as[i]) + } + if i < len(bs) { + bi, _ = strconv.Atoi(bs[i]) + } + switch { + case ai < bi: + return -1 + case ai > bi: + return 1 + } + } + return 0 +} + +// NoMatchError is returned when no asset satisfies a selector. It lists the +// available variants of the requested kind to help the user. +type NoMatchError struct { + Selector Selector + Available []string +} + +func (e *NoMatchError) Error() string { + sel := e.Selector + threading := "nts" + if sel.ZTS { + threading = "ts" + } + want := fmt.Sprintf("%s php%s %s %s/%s", sel.Kind, orAny(sel.Series), threading, sel.OS, sel.Arch) + if len(e.Available) == 0 { + return fmt.Sprintf("no matching %s asset (wanted %s); none available in this release", sel.Kind, want) + } + return fmt.Sprintf("no matching %s asset (wanted %s); available: %s", + sel.Kind, want, strings.Join(e.Available, ", ")) +} + +func orAny(s string) string { + if s == "" { + return "(latest)" + } + return s +} + +// availableVariants lists the parseable assets of a kind as human-readable +// variant strings, sorted and de-duplicated. +func availableVariants(assets []Asset, kind Kind) []string { + seen := map[string]bool{} + var out []string + for _, a := range assets { + p, ok := ParseAssetName(a.Name) + if !ok || p.Kind != kind { + continue + } + threading := "nts" + if p.ZTS { + threading = "ts" + } + v := fmt.Sprintf("php%s %s %s/%s", p.Series, threading, p.OS, p.Arch) + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + sort.Strings(out) + return out +} diff --git a/internal/release/naming_test.go b/internal/release/naming_test.go new file mode 100644 index 0000000..5d1b6b8 --- /dev/null +++ b/internal/release/naming_test.go @@ -0,0 +1,205 @@ +package release + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/php-debugger/installer/internal/platform" +) + +// loadFixture reads the recorded latest-release payload. +func loadFixture(t *testing.T) *Release { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "latest.json")) + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + var rel Release + if err := json.Unmarshal(data, &rel); err != nil { + t.Fatalf("parsing fixture: %v", err) + } + return &rel +} + +func TestParseAssetName(t *testing.T) { + tests := []struct { + name string + want ParsedAsset + ok bool + }{ + { + name: "php-php8.3-nts-linux-x86_64", + want: ParsedAsset{Interpreter, "8.3", false, platform.Linux, platform.X8664}, + ok: true, + }, + { + name: "php-debugger-php8.3-ts-macos-arm64.so", + want: ParsedAsset{Extension, "8.3", true, platform.MacOS, platform.Arm64}, + ok: true, + }, + { + name: "php-debugger-php8.3-nts-windows-x86_64.exe", + want: ParsedAsset{Interpreter, "8.3", false, platform.Windows, platform.X8664}, + ok: true, + }, + { + name: "php-debugger-php8.3-nts-windows-x86_64.dll", + want: ParsedAsset{Extension, "8.3", false, platform.Windows, platform.X8664}, + ok: true, + }, + { + // tolerant of an extra marker token + name: "php-debugger-interp-php8.2-ts-linux-arm64", + want: ParsedAsset{Interpreter, "8.2", true, platform.Linux, platform.Arm64}, + ok: true, + }, + {"checksums.txt", ParsedAsset{}, false}, + {"php-debugger-php8.3-nts-linux", ParsedAsset{}, false}, // no arch + {"php-debugger-nts-linux-x86_64", ParsedAsset{}, false}, // no version + {"php-debugger-php8.3-linux-x86_64", ParsedAsset{}, false}, // no threading + {"", ParsedAsset{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseAssetName(tt.name) + if ok != tt.ok { + t.Fatalf("ParseAssetName(%q) ok = %v, want %v", tt.name, ok, tt.ok) + } + if ok && got != tt.want { + t.Errorf("ParseAssetName(%q) = %+v, want %+v", tt.name, got, tt.want) + } + }) + } +} + +func TestSelectAsset(t *testing.T) { + rel := loadFixture(t) + + tests := []struct { + name string + sel Selector + wantName string + }{ + { + name: "interpreter exact series", + sel: Selector{Interpreter, "8.3", false, platform.Linux, platform.X8664}, + wantName: "php-php8.3-nts-linux-x86_64", + }, + { + name: "interpreter latest series when empty", + sel: Selector{Interpreter, "", false, platform.Linux, platform.X8664}, + wantName: "php-php8.4-nts-linux-x86_64", + }, + { + name: "interpreter zts", + sel: Selector{Interpreter, "8.3", true, platform.Linux, platform.X8664}, + wantName: "php-php8.3-ts-linux-x86_64", + }, + { + name: "interpreter macos arm64", + sel: Selector{Interpreter, "8.3", false, platform.MacOS, platform.Arm64}, + wantName: "php-php8.3-nts-macos-arm64", + }, + { + name: "interpreter windows exe", + sel: Selector{Interpreter, "8.3", false, platform.Windows, platform.X8664}, + wantName: "php-php8.3-nts-windows-x86_64.exe", + }, + { + name: "extension so", + sel: Selector{Extension, "8.3", false, platform.Linux, platform.X8664}, + wantName: "php-debugger-php8.3-nts-linux-x86_64.so", + }, + { + name: "extension dll", + sel: Selector{Extension, "8.3", false, platform.Windows, platform.X8664}, + wantName: "php-debugger-php8.3-nts-windows-x86_64.dll", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SelectAsset(rel.Assets, tt.sel) + if err != nil { + t.Fatalf("SelectAsset: %v", err) + } + if got.Name != tt.wantName { + t.Errorf("SelectAsset = %q, want %q", got.Name, tt.wantName) + } + }) + } +} + +func TestSelectAssetNoMatch(t *testing.T) { + rel := loadFixture(t) + + // arm64 windows interpreter does not exist in the fixture. + _, err := SelectAsset(rel.Assets, Selector{Interpreter, "8.3", false, platform.Windows, platform.Arm64}) + var nme *NoMatchError + if !errors.As(err, &nme) { + t.Fatalf("expected *NoMatchError, got %v", err) + } + if len(nme.Available) == 0 { + t.Error("NoMatchError should list available interpreter variants") + } + + // an unknown series + if _, err := SelectAsset(rel.Assets, Selector{Interpreter, "9.9", false, platform.Linux, platform.X8664}); err == nil { + t.Error("expected error for unknown series") + } +} + +func TestLatestSeries(t *testing.T) { + rel := loadFixture(t) + + got, err := LatestSeries(rel.Assets, Interpreter, false, platform.Linux, platform.X8664) + if err != nil { + t.Fatalf("LatestSeries: %v", err) + } + if got != "8.4" { + t.Errorf("LatestSeries = %q, want 8.4", got) + } + + // no ts interpreter for macos/x86_64 in fixture + if _, err := LatestSeries(rel.Assets, Interpreter, true, platform.MacOS, platform.X8664); err == nil { + t.Error("expected error when no variant matches") + } +} + +func TestCompareSeries(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"8.3", "8.2", 1}, + {"8.2", "8.3", -1}, + {"8.3", "8.3", 0}, + {"8.10", "8.9", 1}, + {"8.3.1", "8.3", 1}, + {"8.3", "8.3.0", 0}, + } + for _, tt := range tests { + if got := compareSeries(tt.a, tt.b); got != tt.want { + t.Errorf("compareSeries(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestAvailableVariantsDeduped(t *testing.T) { + rel := loadFixture(t) + got := availableVariants(rel.Assets, Extension) + // extensions in fixture: 8.3 nts linux/x86_64, 8.3 ts macos/arm64, + // 8.3 nts windows/x86_64, 8.2 nts linux/x86_64 + want := []string{ + "php8.2 nts linux/x86_64", + "php8.3 nts linux/x86_64", + "php8.3 nts windows/x86_64", + "php8.3 ts macos/arm64", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("availableVariants(Extension) = %v, want %v", got, want) + } +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..78b508e --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,161 @@ +// Package release fetches the latest release from the php-debugger GitHub +// repository, selects the right asset for a given platform/PHP variant, and +// downloads it. Asset-name interpretation lives in naming.go — the single place +// that encodes the release naming convention. +package release + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +const ( + // DefaultOwner and DefaultRepo identify the upstream repository. + DefaultOwner = "php-debugger" + DefaultRepo = "php-debugger" + + defaultBaseURL = "https://api.github.com" + defaultUserAgent = "php-debugger-installer" + apiVersion = "2022-11-28" +) + +// Release is the subset of a GitHub release we use. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Assets []Asset `json:"assets"` +} + +// Asset is a downloadable file attached to a release. +type Asset struct { + Name string `json:"name"` + DownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// Client talks to the GitHub releases API. +type Client struct { + HTTP *http.Client + BaseURL string + Owner string + Repo string + Token string // optional; sent as a Bearer token when set + UserAgent string +} + +// NewClient returns a Client with sensible defaults, reading GITHUB_TOKEN from +// the environment if present (raising the API rate limit). +func NewClient() *Client { + return &Client{ + HTTP: &http.Client{Timeout: 60 * time.Second}, + BaseURL: defaultBaseURL, + Owner: DefaultOwner, + Repo: DefaultRepo, + Token: os.Getenv("GITHUB_TOKEN"), + UserAgent: defaultUserAgent, + } +} + +// LatestRelease fetches the repository's latest published release. +func (c *Client) LatestRelease(ctx context.Context) (*Release, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL(), c.Owner, c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", c.userAgent()) + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("fetching latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("fetching latest release: unexpected status %s: %s", + resp.Status, string(body)) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decoding latest release: %w", err) + } + return &rel, nil +} + +// Download streams asset to a file inside destDir (created if needed) and returns +// the path to the downloaded file. The file is named after the asset. +func (c *Client) Download(ctx context.Context, asset Asset, destDir string) (string, error) { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("creating download directory: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.DownloadURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", c.userAgent()) + req.Header.Set("Accept", "application/octet-stream") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("downloading %s: %w", asset.Name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("downloading %s: unexpected status %s", asset.Name, resp.Status) + } + + dest := filepath.Join(destDir, asset.Name) + f, err := os.Create(dest) + if err != nil { + return "", fmt.Errorf("creating %s: %w", dest, err) + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(dest) + return "", fmt.Errorf("writing %s: %w", dest, err) + } + if err := f.Close(); err != nil { + os.Remove(dest) + return "", fmt.Errorf("closing %s: %w", dest, err) + } + return dest, nil +} + +func (c *Client) baseURL() string { + if c.BaseURL != "" { + return c.BaseURL + } + return defaultBaseURL +} + +func (c *Client) userAgent() string { + if c.UserAgent != "" { + return c.UserAgent + } + return defaultUserAgent +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 0000000..3b74b2c --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,131 @@ +package release + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestLatestRelease(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", "latest.json")) + if err != nil { + t.Fatal(err) + } + + var gotPath, gotUA, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotUA = r.Header.Get("User-Agent") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Write(fixture) + })) + defer srv.Close() + + c := NewClient() + c.BaseURL = srv.URL + c.Token = "secret-token" + + rel, err := c.LatestRelease(context.Background()) + if err != nil { + t.Fatalf("LatestRelease: %v", err) + } + if rel.TagName != "0.2.0" { + t.Errorf("TagName = %q, want 0.2.0", rel.TagName) + } + if len(rel.Assets) != 13 { + t.Errorf("got %d assets, want 13", len(rel.Assets)) + } + if gotPath != "/repos/php-debugger/php-debugger/releases/latest" { + t.Errorf("request path = %q", gotPath) + } + if gotUA == "" { + t.Error("User-Agent header must be set (GitHub rejects requests without one)") + } + if gotAuth != "Bearer secret-token" { + t.Errorf("Authorization = %q, want Bearer secret-token", gotAuth) + } +} + +func TestLatestReleaseNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + })) + defer srv.Close() + + c := NewClient() + c.BaseURL = srv.URL + c.Token = "" + + if _, err := c.LatestRelease(context.Background()); err == nil { + t.Error("expected error on 404") + } +} + +func TestLatestReleaseNoTokenOmitsAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Write([]byte(`{"tag_name":"0.0.1","assets":[]}`)) + })) + defer srv.Close() + + c := NewClient() + c.BaseURL = srv.URL + c.Token = "" + + if _, err := c.LatestRelease(context.Background()); err != nil { + t.Fatal(err) + } + if gotAuth != "" { + t.Errorf("Authorization should be empty without a token, got %q", gotAuth) + } +} + +func TestDownload(t *testing.T) { + const payload = "fake-php-binary-contents" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(payload)) + })) + defer srv.Close() + + c := NewClient() + destDir := filepath.Join(t.TempDir(), "downloads") + asset := Asset{Name: "php-debugger-php8.3-nts-linux-x86_64", DownloadURL: srv.URL + "/dl/bin"} + + path, err := c.Download(context.Background(), asset, destDir) + if err != nil { + t.Fatalf("Download: %v", err) + } + if filepath.Base(path) != asset.Name { + t.Errorf("downloaded file name = %q, want %q", filepath.Base(path), asset.Name) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != payload { + t.Errorf("downloaded content = %q, want %q", got, payload) + } +} + +func TestDownloadNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + })) + defer srv.Close() + + c := NewClient() + asset := Asset{Name: "x", DownloadURL: srv.URL} + dest := filepath.Join(t.TempDir(), "d") + if _, err := c.Download(context.Background(), asset, dest); err == nil { + t.Error("expected error on 403 download") + } + // the partial file must not be left behind + if _, err := os.Stat(filepath.Join(dest, "x")); !os.IsNotExist(err) { + t.Error("failed download should not leave a file behind") + } +} diff --git a/internal/release/testdata/latest.json b/internal/release/testdata/latest.json new file mode 100644 index 0000000..0d0ec8d --- /dev/null +++ b/internal/release/testdata/latest.json @@ -0,0 +1,19 @@ +{ + "tag_name": "0.2.0", + "name": "php-debugger 0.2.0", + "assets": [ + { "name": "php-php8.4-nts-linux-x86_64", "browser_download_url": "https://example.com/dl/php-php8.4-nts-linux-x86_64", "size": 41000000 }, + { "name": "php-php8.3-nts-linux-x86_64", "browser_download_url": "https://example.com/dl/php-php8.3-nts-linux-x86_64", "size": 40000000 }, + { "name": "php-php8.3-nts-linux-arm64", "browser_download_url": "https://example.com/dl/php-php8.3-nts-linux-arm64", "size": 40000000 }, + { "name": "php-php8.3-ts-linux-x86_64", "browser_download_url": "https://example.com/dl/php-php8.3-ts-linux-x86_64", "size": 40500000 }, + { "name": "php-php8.2-nts-linux-x86_64", "browser_download_url": "https://example.com/dl/php-php8.2-nts-linux-x86_64", "size": 39000000 }, + { "name": "php-php8.3-nts-macos-arm64", "browser_download_url": "https://example.com/dl/php-php8.3-nts-macos-arm64", "size": 42000000 }, + { "name": "php-php8.3-nts-macos-x86_64", "browser_download_url": "https://example.com/dl/php-php8.3-nts-macos-x86_64", "size": 42000000 }, + { "name": "php-php8.3-nts-windows-x86_64.exe", "browser_download_url": "https://example.com/dl/php-php8.3-nts-windows-x86_64.exe", "size": 43000000 }, + { "name": "php-debugger-php8.3-nts-linux-x86_64.so", "browser_download_url": "https://example.com/dl/php-debugger-php8.3-nts-linux-x86_64.so", "size": 2000000 }, + { "name": "php-debugger-php8.3-ts-macos-arm64.so", "browser_download_url": "https://example.com/dl/php-debugger-php8.3-ts-macos-arm64.so", "size": 2100000 }, + { "name": "php-debugger-php8.3-nts-windows-x86_64.dll", "browser_download_url": "https://example.com/dl/php-debugger-php8.3-nts-windows-x86_64.dll", "size": 2200000 }, + { "name": "php-debugger-php8.2-nts-linux-x86_64.so", "browser_download_url": "https://example.com/dl/php-debugger-php8.2-nts-linux-x86_64.so", "size": 1900000 }, + { "name": "checksums.txt", "browser_download_url": "https://example.com/dl/checksums.txt", "size": 1024 } + ] +}