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) } }