package site import ( "bytes" "compress/gzip" "context" "io" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/npfaro/dumbforge/internal/gitutil" ) func TestFullAndIncrementalBuild(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-b", "main") runGit(t, dir, "config", "user.name", "Site Test") runGit(t, dir, "config", "user.email", "site@dumbforge.invalid") write(t, dir, "README.md", "# Hello bucket\n") write(t, dir, "LICENSE", "test license\n") write(t, dir, "payload.bin", "binary\x00payload\n") write(t, dir, "danger.html", "\n") write(t, dir, "docs/guide.md", "# Guide\n\n- first\n- second\n") write(t, dir, "src/main.go", "package main\n\nfunc main() {}\n") write(t, dir, "vector.svg", ``) runGit(t, dir, "add", ".") runGit(t, dir, "commit", "-m", "initial", "-m", "A longer explanation of the initial change.") first := outputGit(t, dir, "rev-parse", "HEAD") generator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner") if err != nil { t.Fatal(err) } full, err := generator.BuildBranch(context.Background(), "main", "", first, true) if err != nil { t.Fatal(err) } assertPage(t, full, "", "Hello bucket") assertPage(t, full, "", "Clone with HTTPS") assertPage(t, full, "", "Import to GitHub") assertPage(t, full, "", `data-copied-label="URL copied — paste on GitHub"`) assertPageDoesNotContain(t, full, "", `github.com/new/import?url=`) assertPage(t, full, "", "Go to file") assertPage(t, full, "", "data-branch-menu") assertPage(t, full, "", "data-copied-icon") assertPage(t, full, "", `data-smart-time`) assertPage(t, full, "", `absolute >= 604800`) assertPage(t, full, "", ".dumbforge/assets/gabriel-mark.svg") assertPage(t, full, "", `.dumbforge/assets/site.css?v=`+generator.AssetVersion()) assertPage(t, full, "", `.dumbforge/branches/main.css`) assertPage(t, full, ".dumbforge/branches/main.css", `content: "1"`) assertPage(t, full, "", `class="counter" data-commit-count`) assertPage(t, full, ".dumbforge/assets/site.css", "@media (prefers-color-scheme: light)") assertPage(t, full, ".dumbforge/assets/site.css", ".commit-list-message { overflow: hidden; color: var(--text);") assertPage(t, full, "", `owner/`) assertPageDoesNotContain(t, full, "", `href="https://example.invalid/repo.git">owner`) assertPage(t, full, "", "Languages") assertPage(t, full, "", "Contributors") assertPage(t, full, "", `class="avatar repo-title-avatar"`) assertPage(t, full, "", "octicon-folder") assertPage(t, full, "blob/main/src/main.go", "chroma") assertPage(t, full, "blob/main/src/main.go", "data-file-tree") assertPage(t, full, "blob/main/LICENSE", `href="https://example.invalid/repo.git/blob/main/LICENSE"`) assertPage(t, full, "blob/main/docs/guide.md", `
`) assertPage(t, full, "blob/main/docs/guide.md", `

Guide

`) assertPage(t, full, "blob/main/docs/guide.md", "
  • first
  • ") assertPageDoesNotContain(t, full, "blob/main/docs/guide.md", `
    `) assertContentType(t, full, "raw/main/danger.html", "text/plain; charset=utf-8") assertContentType(t, full, "raw/main/vector.svg", "text/plain; charset=utf-8") assertPageDoesNotContain(t, full, "blob/main/vector.svg", `
    `) assertPage(t, full, ".dumbforge/assets/gabriel-mark.svg", "`) assertPageDoesNotContain(t, full, "tree/main/src", ``) assertPage(t, full, "tree/main/src", `..`) assertPage(t, full, "tree/main/src", `commit/`+first+`" title="initial">initial`) assertPage(t, full, "tree/main/src", `class="file-date" datetime="`) assertPage(t, full, "tree/main/src", `data-relative-time`) assertPage(t, full, "commits/main", "Commit history") assertPage(t, full, "commits/main", "gravatar.com/avatar") assertPage(t, full, "commits/main", `class="commit-list-message" href="https://example.invalid/repo.git/commit/`+first+`">initial`) assertPage(t, full, "commits/main", `class="secondary-button commit-list-copy" type="button" data-copy="`+first+`"`) assertPage(t, full, "commit/"+first, "changed files") assertPage(t, full, "commit/"+first, `data-diff-file-tree`) assertPage(t, full, "commit/"+first, `data-diff-file data-path="README.md"`) assertPage(t, full, "commit/"+first, `aria-label="Side-by-side diff for README.md"`) assertPage(t, full, "commit/"+first, `class="diff-code new addition`) assertPage(t, full, "commit/"+first, "A longer explanation of the initial change.") assertPageDoesNotContain(t, full, "commit/"+first, "payload.bin") assertPage(t, full, ".dumbforge/files/main.json", `"path":"src/main.go"`) assertPageDoesNotContain(t, full, "", "Search this repository") assertPageDoesNotContain(t, full, "", ">Docs<") assertPageDoesNotContain(t, full, "", "Forged from a static bucket") generator.SetAssetsCurrent(true) write(t, dir, "src/main.go", "package main\n\nfunc main() { println(\"changed\") }\n") runGit(t, dir, "add", "src/main.go") runGit(t, dir, "commit", "-m", "change one file") second := outputGit(t, dir, "rev-parse", "HEAD") incremental, err := generator.BuildBranch(context.Background(), "main", first, second, true) if err != nil { t.Fatal(err) } if len(incremental.Pages) != 7 { t.Fatalf("incremental build generated %d pages, want 7", len(incremental.Pages)) } for _, page := range incremental.Pages { if strings.HasPrefix(page.Route, ".dumbforge/assets/") || page.Route == ".dumbforge/site-version" { t.Fatalf("incremental build unnecessarily regenerated %q", page.Route) } } assertPage(t, incremental, "blob/main/src/main.go", "changed") assertPage(t, incremental, "tree/main/src", "change one file") assertPage(t, incremental, "tree/main/src", `commit/`+second+`" title="change one file">change one file`) assertPage(t, incremental, "", `commit/`+first+`" title="initial">initial`) assertPage(t, incremental, "commits/main", "change one file") assertPage(t, incremental, ".dumbforge/branches/main.css", `content: "2"`) assertPage(t, incremental, "commit/"+second, `class="diff-code old deletion`) assertPage(t, incremental, "commit/"+second, `class="diff-kd"`) assertPage(t, incremental, "commit/"+second, `changed`) assertPage(t, incremental, "commit/"+second, `parent `) assertNoPage(t, incremental, "commit/"+first) upgradeGenerator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner") if err != nil { t.Fatal(err) } upgradeGenerator.SetBranches([]string{"main"}, "main") backfill, err := upgradeGenerator.BuildBranch(context.Background(), "main", first, second, true) if err != nil { t.Fatal(err) } assertPage(t, backfill, "commit/"+first, "initial") assertPage(t, backfill, "commit/"+second, "change one file") } func TestSlashBranchUsesUnambiguousRoutes(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-b", "main") runGit(t, dir, "config", "user.name", "Site Test") runGit(t, dir, "config", "user.email", "site@dumbforge.invalid") write(t, dir, "docs/guide.md", "# Guide\n") runGit(t, dir, "add", ".") runGit(t, dir, "commit", "-m", "initial") oid := outputGit(t, dir, "rev-parse", "HEAD") generator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner") if err != nil { t.Fatal(err) } generator.SetBranches([]string{"main", "feature/demo"}, "main") build, err := generator.BuildBranch(context.Background(), "feature/demo", "", oid, false) if err != nil { t.Fatal(err) } assertPage(t, build, "tree/feature~demo", "feature/demo") assertPage(t, build, "tree/feature~demo/docs", `..`) assertPage(t, build, "blob/feature~demo/docs/guide.md", "Guide") assertPage(t, build, "commits/feature~demo", "Commit history") assertPage(t, build, ".dumbforge/files/feature~demo.json", `/blob/feature~demo/docs/guide.md`) for _, page := range build.Pages { if strings.Contains(page.Route, "feature/demo") { t.Fatalf("ambiguous branch route generated: %q", page.Route) } } } func TestMarkdownDoesNotPassThroughActiveContent(t *testing.T) { rendered, err := renderMarkdown([]byte("\n\n[bad](javascript:alert('nope'))\n")) if err != nil { t.Fatal(err) } result := strings.ToLower(string(rendered)) if strings.Contains(result, "1111111111111111111111111111111111111111") assertPage(t, build, "blob/main/vendor/example", "https://github.com/example/project/commit/1111111111111111111111111111111111111111") assertPage(t, build, "tree/main/vendor", "https://github.com/example/project/commit/1111111111111111111111111111111111111111") assertContentType(t, build, "raw/main/vendor/example", "text/plain; charset=utf-8") } func TestRelativeSubmoduleURLResolvesBesideCurrentRepository(t *testing.T) { if got := browserRepositoryURL("https://forge.example/group/project.git", "../dependency.git"); got != "https://forge.example/group/dependency.git" { t.Fatalf("relative submodule URL = %q", got) } } func TestRepositoryNavigationBuild(t *testing.T) { generator, err := New(gitutil.Git{}, "https://example.invalid/repo.git", "repo.git", "owner") if err != nil { t.Fatal(err) } generator.SetBranches([]string{"topic", "main", "main"}, "main") build, err := generator.RepositoryNavigationBuild([]string{"old"}) if err != nil { t.Fatal(err) } assertPage(t, build, ".dumbforge/repository.json", `"defaultBranch":"main"`) assertPage(t, build, ".dumbforge/repository.json", `"branches":["main","topic"]`) if len(build.Deletes) != 2 || build.Deletes[0] != ".dumbforge/files/old.json" || build.Deletes[1] != ".dumbforge/branches/old.css" { t.Fatalf("navigation deletes = %#v", build.Deletes) } } func TestReadmeMetadataFallbacks(t *testing.T) { readme := []byte("# Project\n\n![badge](badge.svg)\n\nA **small** [Git host](https://example.invalid).\n\nVisit the [project homepage](https://project.example/docs).\n") if got := readmeDescription(readme); got != "A small Git host." { t.Fatalf("description = %q", got) } if got := readmeHomepage(readme); got != "https://project.example/docs" { t.Fatalf("homepage = %q", got) } } func TestRepositoryURLKeepsGitSuffixForDumbforge(t *testing.T) { if got := normalizeRepositoryURL("git+https://dumbforge.dev/dumbforge.git"); got != "https://dumbforge.dev/dumbforge.git" { t.Fatalf("dumbforge repository URL = %q", got) } if got := normalizeRepositoryURL("git+https://github.com/example/project.git"); got != "https://github.com/example/project" { t.Fatalf("GitHub repository URL = %q", got) } } func TestGravatarUsesNormalizedSHA256(t *testing.T) { url := gravatarURL(" USER@example.COM ", "User Example") if !strings.Contains(url, "https://gravatar.com/avatar/b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514") { t.Fatalf("unexpected Gravatar URL %q", url) } if !strings.Contains(url, "d=initials") || !strings.Contains(url, "name=User+Example") { t.Fatalf("Gravatar URL has no initials fallback: %q", url) } } func TestCompressedPageRoundTrip(t *testing.T) { body := bytes.Repeat([]byte("compress me\n"), 1000) compressed, encoding, err := compressedPage(Page{Body: body, ContentType: "text/html; charset=utf-8"}) if err != nil { t.Fatal(err) } if encoding != "gzip" || len(compressed) >= len(body) { t.Fatalf("compression = %q and %d bytes; want gzip smaller than %d", encoding, len(compressed), len(body)) } reader, err := gzip.NewReader(bytes.NewReader(compressed)) if err != nil { t.Fatal(err) } decoded, err := io.ReadAll(reader) if err != nil { t.Fatal(err) } if err := reader.Close(); err != nil { t.Fatal(err) } if !bytes.Equal(decoded, body) { t.Fatal("compressed page did not round-trip") } } func TestFileNavigationChanged(t *testing.T) { if fileNavigationChanged([]gitutil.NameStatus{{Status: "M", Path: "src/main.go"}}) { t.Fatal("content-only modification should not rebuild file navigation") } for _, change := range []gitutil.NameStatus{ {Status: "A", Path: "new.go"}, {Status: "D", Path: "old.go"}, {Status: "M", Path: ".gitmodules"}, } { if !fileNavigationChanged([]gitutil.NameStatus{change}) { t.Fatalf("%+v should rebuild file navigation", change) } } } func TestParsePatchBuildsAlignedSplitRows(t *testing.T) { patch := []byte("diff --git a/a b.txt b/a b.txt\nindex 1111111..2222222 100644\n--- a/a b.txt\t\n+++ b/a b.txt\t\n@@ -1,3 +1,4 @@\n same\n-old one\n-old two\n+new one\n+new two\n+new three\n") files := parsePatch(patch) if len(files) != 1 { t.Fatalf("parsed %d files, want 1", len(files)) } file := files[0] if file.DisplayPath != "a b.txt" || file.Additions != 3 || file.Deletions != 2 { t.Fatalf("unexpected parsed file: %+v", file) } if len(file.Rows) != 5 { t.Fatalf("parsed %d rows, want hunk + context + 3 aligned changes", len(file.Rows)) } last := file.Rows[len(file.Rows)-1] if last.OldPresent || !last.NewPresent || last.NewText != "new three" { t.Fatalf("last aligned row = %+v", last) } } func TestParsePatchLimitsGeneratedRows(t *testing.T) { var patch strings.Builder patch.WriteString("diff --git a/generated.txt b/generated.txt\n--- a/generated.txt\n+++ b/generated.txt\n@@ -0,0 +1,3000 @@\n") for range 3000 { patch.WriteString("+x\n") } files := parsePatch([]byte(patch.String())) if len(files) != 1 || !files[0].Truncated { t.Fatalf("large patch was not marked truncated: %+v", files) } if len(files[0].Rows) > maxFileDiffRows { t.Fatalf("large patch rendered %d rows, limit is %d", len(files[0].Rows), maxFileDiffRows) } if files[0].Additions != 3000 { t.Fatalf("large patch counted %d additions, want 3000", files[0].Additions) } } func assertPage(t *testing.T, build Build, route, contains string) { t.Helper() for _, page := range build.Pages { if page.Route == route && page.Root == (route == "") { if !strings.Contains(string(page.Body), contains) { t.Fatalf("page %q does not contain %q", route, contains) } return } } t.Fatalf("page %q was not generated", route) } func assertPageDoesNotContain(t *testing.T, build Build, route, unwanted string) { t.Helper() for _, page := range build.Pages { if page.Route == route && page.Root == (route == "") { if strings.Contains(string(page.Body), unwanted) { t.Fatalf("page %q unexpectedly contains %q", route, unwanted) } return } } t.Fatalf("page %q was not generated", route) } func assertNoPage(t *testing.T, build Build, route string) { t.Helper() for _, page := range build.Pages { if page.Route == route { t.Fatalf("page %q was unexpectedly generated", route) } } } func assertContentType(t *testing.T, build Build, route, want string) { t.Helper() for _, page := range build.Pages { if page.Route == route { if page.ContentType != want { t.Fatalf("page %q content type = %q, want %q", route, page.ContentType, want) } return } } t.Fatalf("page %q was not generated", route) } func write(t *testing.T, dir, name, contents string) { t.Helper() filename := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filename, []byte(contents), 0o644); err != nil { t.Fatal(err) } } func runGit(t *testing.T, dir string, args ...string) { t.Helper() _ = outputGit(t, dir, args...) } func outputGit(t *testing.T, dir string, args ...string) string { t.Helper() args = append([]string{"-C", dir}, args...) out, err := exec.Command("git", args...).CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return strings.TrimSpace(string(out)) }