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, "/") }