311 lines
9.3 KiB
Go
311 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Run the asset selection against the real release JSON in t/corpus/, offline.
|
|
// Every pick is checked for tokens that contradict the requested platform, so
|
|
// a wrong choice fails loudly instead of just looking plausible. This is the
|
|
// Go twin of t/select.t and must reach the same verdict.
|
|
|
|
var platforms = [][2]string{
|
|
{"darwin", "arm64"}, {"darwin", "amd64"}, {"linux", "amd64"},
|
|
{"linux", "arm64"}, {"windows", "amd64"},
|
|
}
|
|
|
|
// Tokens that must never appear in a pick for the given platform.
|
|
var badOS = map[string][]string{
|
|
"darwin": {"linux", "windows", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
|
"linux": {"darwin", "macos", "osx", "apple", "windows", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
|
"windows": {"linux", "darwin", "macos", "osx", "apple", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
|
}
|
|
|
|
var badArch = map[string][]string{
|
|
"amd64": {"arm64", "aarch64", "armv7", "armhf", "i686", "i386", "riscv", "riscv64", "ppc64", "ppc64le", "s390x", "sparc64", "mips", "mips64"},
|
|
"arm64": {"amd64", "x86_64", "i686", "i386", "riscv", "riscv64", "ppc64", "ppc64le", "s390x", "sparc64", "mips", "mips64"},
|
|
}
|
|
|
|
// Cross-compile targets that are never the host.
|
|
var foreign = []string{"android", "androideabi", "ios", "wasi", "wasm", "wasm32", "emscripten"}
|
|
|
|
var notABinary = regexp.MustCompile(`\.(deb|rpm|pkg|dmg|msi|apk|snap|appimage|asc|sig|sha256)$`)
|
|
|
|
func corpusDir(t *testing.T) string {
|
|
dir := filepath.Join("..", "t", "corpus")
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Skipf("no corpus at %s", dir)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func tok(name, word string) bool {
|
|
return regexp.MustCompile(`(^|[^a-z0-9])` + regexp.QuoteMeta(word) + `([^a-z0-9]|$)`).MatchString(name)
|
|
}
|
|
|
|
func TestCorpusSelection(t *testing.T) {
|
|
dir := corpusDir(t)
|
|
|
|
bins := map[string]string{}
|
|
if body, err := os.ReadFile(filepath.Join(dir, "binaries.json")); err == nil {
|
|
json.Unmarshal(body, &bins)
|
|
}
|
|
|
|
files, err := filepath.Glob(filepath.Join(dir, "*__*.json"))
|
|
if err != nil || len(files) == 0 {
|
|
t.Fatalf("no corpus files in %s", dir)
|
|
}
|
|
sort.Strings(files)
|
|
|
|
stats := map[string]int{}
|
|
gaps := map[string][]string{}
|
|
|
|
for _, file := range files {
|
|
slug := strings.TrimSuffix(filepath.Base(file), ".json")
|
|
repo := strings.Replace(slug, "__", "/", 1)
|
|
|
|
body, err := os.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", file, err)
|
|
}
|
|
var rel Release
|
|
if err := json.Unmarshal(body, &rel); err != nil {
|
|
t.Fatalf("%s: %v", file, err)
|
|
}
|
|
if len(rel.Assets) == 0 {
|
|
t.Errorf("%s: no assets in release", repo)
|
|
continue
|
|
}
|
|
|
|
bin := bins[repo]
|
|
if bin == "" {
|
|
bin = repo[strings.LastIndex(repo, "/")+1:]
|
|
}
|
|
|
|
for _, p := range platforms {
|
|
goos, goarch := p[0], p[1]
|
|
hit, err := pickAsset(rel.Assets, bin, goos, goarch, &Spec{})
|
|
if err != nil {
|
|
t.Errorf("%s %s/%s: %v", repo, goos, goarch, err)
|
|
continue
|
|
}
|
|
if hit == nil {
|
|
stats["no match"]++
|
|
gaps[repo] = append(gaps[repo], goos+"/"+goarch)
|
|
continue
|
|
}
|
|
|
|
name := strings.ToLower(hit.Name)
|
|
flag := ""
|
|
for _, w := range badOS[goos] {
|
|
if tok(name, w) {
|
|
flag = "WRONG OS (" + w + ")"
|
|
}
|
|
}
|
|
for _, w := range badArch[goarch] {
|
|
if tok(name, w) {
|
|
flag = "WRONG ARCH (" + w + ")"
|
|
}
|
|
}
|
|
for _, w := range foreign {
|
|
if tok(name, w) {
|
|
flag = "FOREIGN TARGET (" + w + ")"
|
|
}
|
|
}
|
|
if flag == "" && goos != "windows" && strings.HasSuffix(name, ".exe") {
|
|
flag = "EXE ON UNIX"
|
|
}
|
|
if flag == "" && notABinary.MatchString(name) {
|
|
flag = "NOT A BINARY"
|
|
}
|
|
if flag == "" && !unpackable(name) {
|
|
flag = "UNPACKABLE?"
|
|
}
|
|
if flag != "" {
|
|
t.Errorf("%s %s/%s -> %s [%s]", repo, goos, goarch, hit.Name, flag)
|
|
stats[flag]++
|
|
continue
|
|
}
|
|
stats["ok"]++
|
|
}
|
|
}
|
|
|
|
var summary []string
|
|
for _, k := range sortedKeys(stats) {
|
|
summary = append(summary, fmt.Sprintf("%s=%d", k, stats[k]))
|
|
}
|
|
t.Logf("Totals: %s", strings.Join(summary, ", "))
|
|
if len(gaps) > 0 {
|
|
var lines []string
|
|
for _, repo := range sortedKeys(gaps) {
|
|
lines = append(lines, fmt.Sprintf("%s (%s)", repo, strings.Join(gaps[repo], " ")))
|
|
}
|
|
t.Logf("Unmatched: %s", strings.Join(lines, ", "))
|
|
}
|
|
}
|
|
|
|
// The Perl version reaches these totals on the same corpus; a change here is a
|
|
// change in behaviour and wants a look, not a blind update.
|
|
func TestCorpusTotalsMatchPerl(t *testing.T) {
|
|
dir := corpusDir(t)
|
|
files, _ := filepath.Glob(filepath.Join(dir, "*__*.json"))
|
|
|
|
bins := map[string]string{}
|
|
if body, err := os.ReadFile(filepath.Join(dir, "binaries.json")); err == nil {
|
|
json.Unmarshal(body, &bins)
|
|
}
|
|
|
|
ok, misses := 0, 0
|
|
for _, file := range files {
|
|
slug := strings.TrimSuffix(filepath.Base(file), ".json")
|
|
repo := strings.Replace(slug, "__", "/", 1)
|
|
body, _ := os.ReadFile(file)
|
|
var rel Release
|
|
if json.Unmarshal(body, &rel) != nil {
|
|
continue
|
|
}
|
|
bin := bins[repo]
|
|
if bin == "" {
|
|
bin = repo[strings.LastIndex(repo, "/")+1:]
|
|
}
|
|
for _, p := range platforms {
|
|
hit, err := pickAsset(rel.Assets, bin, p[0], p[1], &Spec{})
|
|
switch {
|
|
case err != nil:
|
|
t.Fatalf("%s: %v", repo, err)
|
|
case hit == nil:
|
|
misses++
|
|
default:
|
|
ok++
|
|
}
|
|
}
|
|
}
|
|
if ok != 141 || misses != 9 {
|
|
t.Errorf("corpus totals drifted: ok=%d no-match=%d, want ok=141 no-match=9", ok, misses)
|
|
}
|
|
}
|
|
|
|
func TestParseRepo(t *testing.T) {
|
|
cases := []struct{ in, base, owner, repo string }{
|
|
{"https://github.com/sxyazi/yazi", "https://github.com", "sxyazi", "yazi"},
|
|
{"https://git.micw.org/mike/dns", "https://git.micw.org", "mike", "dns"},
|
|
{"https://git.micw.org/mike/dns.git", "https://git.micw.org", "mike", "dns"},
|
|
{"https://git.micw.org/mike/dns/", "https://git.micw.org", "mike", "dns"},
|
|
{"git.micw.org/mike/dns", "https://git.micw.org", "mike", "dns"},
|
|
{"https://example.org/gitea/mike/dns", "https://example.org/gitea", "mike", "dns"},
|
|
{"http://localhost:3000/mike/dns", "http://localhost:3000", "mike", "dns"},
|
|
}
|
|
for _, c := range cases {
|
|
base, owner, repo, err := parseRepo(c.in)
|
|
if err != nil {
|
|
t.Errorf("%s: %v", c.in, err)
|
|
continue
|
|
}
|
|
if base != c.base || owner != c.owner || repo != c.repo {
|
|
t.Errorf("%s -> %s %s %s, want %s %s %s", c.in, base, owner, repo, c.base, c.owner, c.repo)
|
|
}
|
|
}
|
|
for _, bad := range []string{"https://github.com/onlyowner", "https://github.com"} {
|
|
if _, _, _, err := parseRepo(bad); err == nil {
|
|
t.Errorf("%s: expected an error", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForgeDetection(t *testing.T) {
|
|
cases := []struct{ base, forge, api string }{
|
|
{"https://github.com", "github", "https://api.github.com"},
|
|
{"https://git.micw.org", "gitea", "https://git.micw.org/api/v1"},
|
|
{"https://example.org/gitea", "gitea", "https://example.org/gitea/api/v1"},
|
|
{"https://notgithub.com", "gitea", "https://notgithub.com/api/v1"},
|
|
}
|
|
for _, c := range cases {
|
|
f, err := detectForge(c.base, "")
|
|
if err != nil || f != c.forge {
|
|
t.Errorf("%s -> %q (%v), want %q", c.base, f, err, c.forge)
|
|
}
|
|
if got := apiBase(c.base, f); got != c.api {
|
|
t.Errorf("%s -> %s, want %s", c.base, got, c.api)
|
|
}
|
|
}
|
|
if _, err := detectForge("https://x", "gitlab"); err == nil {
|
|
t.Error("expected an error for an unknown forge")
|
|
}
|
|
// GitHub Enterprise needs the override and lands on /api/v3.
|
|
if got := apiBase("https://gh.corp.example", "github"); got != "https://gh.corp.example/api/v3" {
|
|
t.Errorf("enterprise API base: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestParseNames(t *testing.T) {
|
|
cases := []struct {
|
|
list, fallback string
|
|
want []name
|
|
}{
|
|
{"", "yazi", []name{{"yazi", "yazi"}}},
|
|
{"yazi,ya", "x", []name{{"yazi", "yazi"}, {"ya", "ya"}}},
|
|
{"yazi:yazi-nightly", "x", []name{{"yazi", "yazi-nightly"}}},
|
|
{"a , b", "x", []name{{"a", "a"}, {"b", "b"}}},
|
|
}
|
|
for _, c := range cases {
|
|
got := parseNames(c.list, c.fallback)
|
|
if len(got) != len(c.want) {
|
|
t.Errorf("%q -> %v, want %v", c.list, got, c.want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("%q -> %v, want %v", c.list, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitTokens(t *testing.T) {
|
|
got := splitTokens(`https://x/y install=~/bin name="a b" pre`)
|
|
want := []string{"https://x/y", "install=~/bin", "name=a b", "pre"}
|
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTokens(t *testing.T) {
|
|
cases := [][2]string{
|
|
{"pnpm-win32-x64.zip", "pnpm-windows-x64.zip"},
|
|
{"tool-win64.zip", "tool-windows-amd64.zip"},
|
|
{"winsomething-x64", "winsomething-x64"}, // not a token, left alone
|
|
}
|
|
for _, c := range cases {
|
|
if got := normalizeTokens(c[0]); got != c[1] {
|
|
t.Errorf("%s -> %s, want %s", c[0], got, c[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateFileLayout(t *testing.T) {
|
|
t.Setenv("XDG_STATE_HOME", "/tmp/state")
|
|
// Same recipe as the Perl version: sanitised path, dot, 8 hex characters.
|
|
got := filepath.Base(stateFile("/home/mike/bin/fzf"))
|
|
if !regexp.MustCompile(`^home_mike_bin_fzf\.[0-9a-f]{8}\.json$`).MatchString(got) {
|
|
t.Errorf("unexpected state file name: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestSafeJoin(t *testing.T) {
|
|
for _, bad := range []string{"../evil", "/etc/passwd", "a/../../evil"} {
|
|
if _, ok := safeJoin("/dest", bad); ok {
|
|
t.Errorf("%q should have been rejected", bad)
|
|
}
|
|
}
|
|
if p, ok := safeJoin("/dest", "sub/bin/tool"); !ok || p != filepath.Join("/dest", "sub/bin/tool") {
|
|
t.Errorf("safeJoin rejected a normal path: %s %v", p, ok)
|
|
}
|
|
}
|