Files
kage/urlx/urlx_test.go
T
Duc-Tam Nguyen 249761f29f Leave bulk and off-domain assets remote, skip over-cap downloads
A developer.apple.com crawl came back at 19 GB, 18 of it assets, most of
that the site's own videos, .dmg/.pkg installers, and PDF manuals pulled
from a couple dozen hosts including unrelated third parties. None of it
helps read the docs offline. This changes what kage localizes by default.

Bulk media, installers, archives, and PDFs are left pointing at their
live URL instead of downloaded. The decision is made from the URL alone,
so the rewritten HTML simply keeps the remote link. --keep-media restores
the old behavior and --skip-ext adds more extensions to leave remote.

Assets are localized only from the seed's registrable domain by default,
so www.apple.com and images.apple.com still come along but a separate
brand domain or an off-topic third party does not. --all-asset-hosts goes
back to downloading from any host.

The size cap was also truncating instead of skipping: it wrapped the body
in a LimitReader, so an over-cap file was saved as exactly the first N MB
of itself, a corrupt fragment that would never play or run. On the apple
crawl that was around a gigabyte of half-downloaded WWDC videos. kage now
checks the response size and leaves an over-cap asset out of the mirror.
2026-06-15 20:27:47 +07:00

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