9c9576b5b9
ci / test (macos-latest) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / lint (push) Has been cancelled
ci / govulncheck (push) Has been cancelled
ci / tidy (push) Has been cancelled
ci / webview (push) Has been cancelled
ci / windows-gui (push) Has been cancelled
release / check (push) Has been cancelled
release / release (push) Has been cancelled
Docs / build (push) Has been cancelled
Docs / deploy-pages (push) Has been cancelled
Docs / deploy-cloudflare (push) Has been cancelled
293 lines
8.7 KiB
Go
293 lines
8.7 KiB
Go
package urlx
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func mustParse(t *testing.T, s string) *url.URL {
|
|
t.Helper()
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", s, err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestParseSeed(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
err bool
|
|
}{
|
|
{"example.com", "https://example.com/", false},
|
|
{"https://example.com", "https://example.com/", false},
|
|
{"http://ex.com/docs", "http://ex.com/docs", false},
|
|
{"HTTPS://Example.COM/A", "https://example.com/A", false},
|
|
{"https://ex.com:443/", "https://ex.com/", false},
|
|
{"http://ex.com:80/", "http://ex.com/", false},
|
|
{"", "", true},
|
|
{"http://", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := ParseSeed(c.in)
|
|
if c.err {
|
|
if err == nil {
|
|
t.Errorf("ParseSeed(%q) want error, got %v", c.in, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("ParseSeed(%q) unexpected error: %v", c.in, err)
|
|
continue
|
|
}
|
|
if got.String() != c.want {
|
|
t.Errorf("ParseSeed(%q) = %q, want %q", c.in, got.String(), c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalize(t *testing.T) {
|
|
base := mustParse(t, "https://ex.com/docs/intro")
|
|
cases := []struct {
|
|
ref string
|
|
want string
|
|
err bool
|
|
}{
|
|
{"../guide", "https://ex.com/guide", false},
|
|
{"/a/b/../c", "https://ex.com/a/c", false},
|
|
{"page#frag", "https://ex.com/docs/page", false},
|
|
{"//cdn.io/x.css", "https://cdn.io/x.css", false},
|
|
{"HTTPS://Other.COM/Y", "https://other.com/Y", false},
|
|
{"sub/", "https://ex.com/docs/sub/", false},
|
|
// A query busted with a raw date string must come back with its spaces
|
|
// percent-encoded so the request line is valid, while the commas and
|
|
// colons a query legally carries stay as they are.
|
|
{"a.css?Thursday, 26-Feb-2026 16:26:41 UTC", "https://ex.com/docs/a.css?Thursday,%2026-Feb-2026%2016:26:41%20UTC", false},
|
|
{"b.css?v=1&t=a b", "https://ex.com/docs/b.css?v=1&t=a%20b", false},
|
|
{"c.css?x=%20done", "https://ex.com/docs/c.css?x=%20done", false},
|
|
{"#top", "", true},
|
|
{"javascript:void(0)", "", true},
|
|
{"mailto:a@b.com", "", true},
|
|
{"data:image/png;base64,xxx", "", true},
|
|
{"tel:+123", "", true},
|
|
{"", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := Normalize(base, c.ref)
|
|
if c.err {
|
|
if err == nil {
|
|
t.Errorf("Normalize(%q) want error, got %v", c.ref, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("Normalize(%q) unexpected error: %v", c.ref, err)
|
|
continue
|
|
}
|
|
if got.String() != c.want {
|
|
t.Errorf("Normalize(%q) = %q, want %q", c.ref, got.String(), c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInScope(t *testing.T) {
|
|
seed := mustParse(t, "https://ex.com/")
|
|
cases := []struct {
|
|
u string
|
|
cfg ScopeConfig
|
|
want bool
|
|
}{
|
|
{"https://ex.com/a", ScopeConfig{}, true},
|
|
{"https://other.com/a", ScopeConfig{}, false},
|
|
{"https://sub.ex.com/a", ScopeConfig{}, false},
|
|
{"https://sub.ex.com/a", ScopeConfig{IncludeSubdomains: true}, true},
|
|
{"https://ex.com/docs/x", ScopeConfig{ScopePrefix: "/docs/"}, true},
|
|
{"https://ex.com/blog/x", ScopeConfig{ScopePrefix: "/docs/"}, false},
|
|
{"https://ex.com/a/private/x", ScopeConfig{ExcludePaths: []string{"/private/"}}, false},
|
|
{"https://ex.com/a/public/x", ScopeConfig{ExcludePaths: []string{"/private/"}}, true},
|
|
}
|
|
for _, c := range cases {
|
|
got := InScope(seed, mustParse(t, c.u), c.cfg)
|
|
if got != c.want {
|
|
t.Errorf("InScope(%q, %+v) = %v, want %v", c.u, c.cfg, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSameRegistrableDomain(t *testing.T) {
|
|
seed := mustParse(t, "https://developer.apple.com/")
|
|
cases := map[string]bool{
|
|
"https://developer.apple.com/x": true,
|
|
"https://www.apple.com/x": true,
|
|
"https://images.apple.com/x": true,
|
|
"https://apple.com/x": true,
|
|
"https://cdn-apple.com/x": false,
|
|
"https://ec.europa.eu/x": false,
|
|
"https://mmbiz.qpic.cn/x": false,
|
|
}
|
|
for u, want := range cases {
|
|
if got := SameRegistrableDomain(seed, mustParse(t, u)); got != want {
|
|
t.Errorf("SameRegistrableDomain(%q) = %v, want %v", u, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExt(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://ex.com/a/clip.mp4": ".mp4",
|
|
"https://ex.com/a/clip.MP4?v=2": ".mp4",
|
|
"https://ex.com/style.css": ".css",
|
|
"https://ex.com/docs/": "",
|
|
"https://ex.com/docs": "",
|
|
"https://ex.com/Xcode_15.0.dmg": ".dmg",
|
|
}
|
|
for u, want := range cases {
|
|
if got := Ext(mustParse(t, u)); got != want {
|
|
t.Errorf("Ext(%q) = %q, want %q", u, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLikelyPage(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"https://ex.com/docs": true,
|
|
"https://ex.com/docs/": true,
|
|
"https://ex.com/a.html": true,
|
|
"https://ex.com/file.pdf": false,
|
|
"https://ex.com/img.png": false,
|
|
"https://ex.com/style.css": false,
|
|
"https://ex.com/app.js": false,
|
|
}
|
|
for u, want := range cases {
|
|
if got := LikelyPage(mustParse(t, u)); got != want {
|
|
t.Errorf("LikelyPage(%q) = %v, want %v", u, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalPathPages(t *testing.T) {
|
|
seed := "ex.com"
|
|
cases := []struct {
|
|
u string
|
|
want string
|
|
}{
|
|
{"https://ex.com/", "index.html"},
|
|
{"https://ex.com/docs", "docs/index.html"},
|
|
{"https://ex.com/docs/", "docs/index.html"},
|
|
{"https://ex.com/docs/intro", "docs/intro/index.html"},
|
|
{"https://ex.com/a.html", "a.html/index.html"},
|
|
{"https://sub.ex.com/x", "sub.ex.com/x/index.html"},
|
|
// A directory-index document is the directory itself, so it shares a
|
|
// path with the bare directory and with the http/https variant.
|
|
{"https://ex.com/index.html", "index.html"},
|
|
{"http://ex.com/", "index.html"},
|
|
{"https://ex.com/docs/index.html", "docs/index.html"},
|
|
}
|
|
for _, c := range cases {
|
|
got := LocalPath(seed, mustParse(t, c.u), Page, "")
|
|
if got != c.want {
|
|
t.Errorf("LocalPath(page %q) = %q, want %q", c.u, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalPathPageQuery(t *testing.T) {
|
|
got := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=cats"), Page, "")
|
|
if !strings.HasPrefix(got, "search/index__q-") || !strings.HasSuffix(got, ".html") {
|
|
t.Errorf("query page mapped to %q", got)
|
|
}
|
|
// Stable hash.
|
|
got2 := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=cats"), Page, "")
|
|
if got != got2 {
|
|
t.Errorf("query hash not stable: %q vs %q", got, got2)
|
|
}
|
|
// Different query → different file.
|
|
got3 := LocalPath("ex.com", mustParse(t, "https://ex.com/search?q=dogs"), Page, "")
|
|
if got == got3 {
|
|
t.Errorf("different queries collided: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLocalPathAssets(t *testing.T) {
|
|
seed := "ex.com"
|
|
cases := []struct {
|
|
u string
|
|
want string
|
|
}{
|
|
{"https://ex.com/css/main.css", "_kage/ex.com/css/main.css"},
|
|
{"https://cdn.io/font/x.woff2", "_kage/cdn.io/font/x.woff2"},
|
|
{"https://ex.com/avatar", "_kage/ex.com/avatar"},
|
|
{"https://ex.com/assets/", "_kage/ex.com/assets/index"},
|
|
}
|
|
for _, c := range cases {
|
|
got := LocalPath(seed, mustParse(t, c.u), Asset, "")
|
|
if got != c.want {
|
|
t.Errorf("LocalPath(asset %q) = %q, want %q", c.u, got, c.want)
|
|
}
|
|
}
|
|
// Query on an asset folds into the filename before the extension.
|
|
got := LocalPath(seed, mustParse(t, "https://ex.com/img/logo.png?v=3"), Asset, "")
|
|
if !strings.HasPrefix(got, "_kage/ex.com/img/logo__q-") || !strings.HasSuffix(got, ".png") {
|
|
t.Errorf("asset query mapped to %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRel(t *testing.T) {
|
|
cases := []struct {
|
|
from string // page file
|
|
to string // target file
|
|
want string
|
|
}{
|
|
{"docs/intro/index.html", "docs/index.html", "../index.html"},
|
|
{"docs/intro/index.html", "index.html", "../../index.html"},
|
|
{"docs/intro/index.html", "_kage/ex.com/css/main.css", "../../_kage/ex.com/css/main.css"},
|
|
{"index.html", "docs/index.html", "docs/index.html"},
|
|
{"index.html", "_kage/ex.com/x.png", "_kage/ex.com/x.png"},
|
|
{"a/b/c/index.html", "a/b/c/index.html", "index.html"},
|
|
}
|
|
for _, c := range cases {
|
|
got := Rel(Dir(c.from), c.to)
|
|
if got != c.want {
|
|
t.Errorf("Rel(dir(%q), %q) = %q, want %q", c.from, c.to, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelResolves checks the invariant that follows: joining a page's directory
|
|
// with the relative link Rel produced lands exactly on the target file.
|
|
func TestRelResolves(t *testing.T) {
|
|
pairs := [][2]string{
|
|
{"docs/intro/index.html", "_kage/cdn.io/a/b.css"},
|
|
{"index.html", "deep/a/b/c/index.html"},
|
|
{"x/y/index.html", "x/index.html"},
|
|
}
|
|
for _, p := range pairs {
|
|
from, to := p[0], p[1]
|
|
rel := Rel(Dir(from), to)
|
|
joined := joinAndClean(Dir(from), rel)
|
|
if joined != to {
|
|
t.Errorf("Rel round-trip: from=%q to=%q rel=%q joined=%q", from, to, rel, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func joinAndClean(dir, rel string) string {
|
|
full := dir + "/" + rel
|
|
parts := splitNonEmpty(full)
|
|
var stack []string
|
|
for _, p := range parts {
|
|
switch p {
|
|
case ".":
|
|
case "..":
|
|
if len(stack) > 0 {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
default:
|
|
stack = append(stack, p)
|
|
}
|
|
}
|
|
return strings.Join(stack, "/")
|
|
}
|