oni.git

oni

~ A static Git browser written in Rust.
git clone https://github.com/debuman/oni.git

scripts/test_generator.py (13789B)

      1 #!/usr/bin/env python3
      2 """Exercise the Rust generator against local Git fixtures; no network is used."""
      3 import os
      4 from pathlib import Path
      5 import subprocess
      6 import tempfile
      7 from urllib.parse import unquote, urlsplit
      8 from html.parser import HTMLParser
      9 import xml.etree.ElementTree as ET
     10 from readme_assets import ReadmeLinks, link_readme_assets
     11 
     12 ROOT = Path(__file__).resolve().parents[1]
     13 BINARY = ROOT / ".cache/cargo/release/sqzer-stagit"
     14 
     15 
     16 def run(*args, cwd=None, env=None):
     17     return subprocess.check_output([str(arg) for arg in args], cwd=cwd, env=env, timeout=60)
     18 
     19 
     20 class Links(HTMLParser):
     21     def __init__(self):
     22         super().__init__()
     23         self.links = []
     24 
     25     def handle_starttag(self, tag, attrs):
     26         self.links.extend(value for key, value in attrs if key == "href")
     27 
     28 def assert_local_links(out):
     29     for page in out.rglob("*.html"):
     30         parser = Links()
     31         parser.feed(page.read_text())
     32         for href in parser.links:
     33             url = urlsplit(href)
     34             if url.scheme or url.netloc or not url.path:
     35                 continue
     36             if url.path.endswith(("style.css", "archive.tar.gz")):
     37                 continue
     38             assert (page.parent / unquote(url.path)).is_file(), (page, href)
     39 
     40 
     41 def regressions(root, env):
     42     def fixture(name, files):
     43         repo = root / name
     44         repo.mkdir()
     45         run("git", "init", "-q", "--initial-branch=main", repo)
     46         run("git", "-C", repo, "config", "user.name", "Tester")
     47         run("git", "-C", repo, "config", "user.email", "test@example.invalid")
     48         for name, content in files.items():
     49             target = repo / name
     50             target.parent.mkdir(parents=True, exist_ok=True)
     51             target.write_text(content)
     52         commit(repo, "Initial")
     53         return repo
     54 
     55     def commit(repo, subject):
     56         run("git", "-C", repo, "add", "-A")
     57         run("git", "-C", repo, "commit", "-qm", subject, env=env)
     58         return run("git", "-C", repo, "rev-parse", "HEAD").decode().strip()
     59 
     60     def generate(repo):
     61         out = root / (repo.name + "-output")
     62         run(BINARY, "-u", "https://git.example/fixture/", "--output", out, repo)
     63         assert_local_links(out)
     64         return out
     65 
     66     repo = fixture("renames", {"src/old 한 글#?.txt": "one\ntwo\n", "source.txt": "source content\n"})
     67     run("git", "-C", repo, "mv", "src/old 한 글#?.txt", "src/new 한 글#?.txt")
     68     renamed = commit(repo, "Rename only")
     69     (repo / "copy.txt").write_text((repo / "source.txt").read_text())
     70     (repo / "source.txt").write_text("source content\nnew line\n")
     71     copied = commit(repo, "Copy and modify")
     72     out = generate(repo)
     73     page = (out / "commit" / (renamed + ".html")).read_text()
     74     assert 'class="R"' in page
     75     assert "old 한 글#?.txt -> src/new 한 글#?.txt" in page
     76     assert "1 file changed, 0 insertion(+), 0 deletion(-)" in page
     77     assert 'class="C"' in (out / "commit" / (copied + ".html")).read_text()
     78 
     79     for name, files, expected, excluded in (
     80         ("lowercase-txt", {"readme.txt": "LOWERCASE TEXT\n"}, "LOWERCASE TEXT", None),
     81         ("lowercase", {"readme": "LOWERCASE README\n"}, "LOWERCASE README", None),
     82         ("readme-order", {"README": "PRIMARY PLAIN\n", "README.md": "# SECONDARY MD\n"},
     83          "PRIMARY PLAIN", "SECONDARY MD"),
     84         ("readme-md-order", {"README.md": "# PRIMARY MD\n", "README.txt": "SECONDARY TEXT\n"},
     85          "<h1>PRIMARY MD</h1>", "SECONDARY TEXT"),
     86     ):
     87         page = (generate(fixture(name, files)) / "README.html").read_text()
     88         assert expected in page
     89         assert excluded is None or excluded not in page
     90 
     91     repo = fixture("release-tags", {"README.md": "# Main\n"})
     92     run("git", "-C", repo, "checkout", "-qb", "release")
     93     (repo / "release.txt").write_text("Release preparation\n")
     94     release_parent = commit(repo, "Prepare separate release")
     95     (repo / "release.txt").write_text("Release finished\n")
     96     release = commit(repo, "Separate release")
     97     run("git", "-C", repo, "tag", "v-special")
     98     run("git", "-C", repo, "tag", "-am", "Annotated release", "v-annotated", env=env)
     99     run("git", "-C", repo, "checkout", "-q", "main")
    100     blob = run("git", "-C", repo, "rev-parse", "HEAD:README.md").decode().strip()
    101     run("git", "-C", repo, "tag", "v-blob", blob)
    102     out = generate(repo)
    103     ns = {"a": "http://www.w3.org/2005/Atom"}
    104     feed = ET.parse(out / "tags.xml").getroot()
    105     entries = feed.findall("a:entry", ns)
    106     assert len(entries) == 2
    107     assert len({entry.findtext("a:id", namespaces=ns) for entry in entries}) == 2
    108     assert {entry.findtext("a:title", namespaces=ns) for entry in entries} == {
    109         "[v-special] Separate release", "[v-annotated] Separate release",
    110     }
    111     assert feed.find("a:updated", ns) is not None
    112     for oid in (release_parent, release):
    113         assert (out / "commit" / (oid + ".html")).is_file()
    114     for entry in entries:
    115         href = entry.find("a:link", ns).attrib["href"]
    116         assert href.endswith(f"/commit/{release}.html")
    117     assert release not in (out / "log.html").read_text()
    118 
    119     repo = fixture("empty-and-merge", {"a.txt": "base\n", "README.md": "# Main\n",
    120                    "LICENSE.txt": "license\n", ".gitmodules": "# no modules\n"})
    121     run("git", "-C", repo, "commit", "--allow-empty", "-qm", "Empty commit", env=env)
    122     empty = run("git", "-C", repo, "rev-parse", "HEAD").decode().strip()
    123     (repo / "a.txt").chmod(0o755)
    124     run("git", "-C", repo, "update-index", "--chmod=+x", "a.txt")
    125     run("git", "-C", repo, "commit", "-qm", "Mode only", env=env)
    126     mode = run("git", "-C", repo, "rev-parse", "HEAD").decode().strip()
    127     run("git", "-C", repo, "checkout", "-qb", "topic")
    128     (repo / "topic.txt").write_text("topic\n")
    129     commit(repo, "Topic change")
    130     run("git", "-C", repo, "checkout", "-q", "main")
    131     (repo / "main.txt").write_text("main\n")
    132     commit(repo, "Main change")
    133     run("git", "-C", repo, "merge", "--no-ff", "-qm", "Merge topic", "topic", env=env)
    134     merged = run("git", "-C", repo, "rev-parse", "HEAD").decode().strip()
    135     out = generate(repo)
    136     assert "0 files changed, 0 insertion(+), 0 deletion(-)" in (out / f"commit/{empty}.html").read_text()
    137     assert "1 file changed, 0 insertion(+), 0 deletion(-)" in (out / f"commit/{mode}.html").read_text()
    138     assert "1 file changed, 1 insertion(+), 0 deletion(-)" in (out / f"commit/{merged}.html").read_text()
    139     page = (out / "file/a.txt.html").read_text()
    140     assert 'href="../file/LICENSE.txt.html"' in page
    141     assert 'href="../file/.gitmodules.html"' in page
    142 
    143     # Stream markers must not collide with raw numstat paths or source lines.
    144     unusual = "stream\noni-end-of-diff\nfile.txt"
    145     (repo / unusual).write_text("oni-end-of-diff\n<source> & text\n")
    146     streamed = commit(repo, "Marker-like filename and content")
    147     out = generate(repo)
    148     assert (out / "file" / (unusual + ".html")).is_file()
    149     page = (out / f"commit/{streamed}.html").read_text()
    150     assert "+oni-end-of-diff</a>" in page
    151     assert "&lt;source&gt; &amp; text" in page
    152 
    153     repo = fixture("readme-links", {"README.md": "# Links\n\n[docs](docs/?q=1#top)\n"
    154                    "[nested](docs/nested)\n[file](docs/nested/a.txt)\n[missing](absent)\n",
    155                    "docs/nested/a.txt": "text\n", "docs-other/b.txt": "text\n"})
    156     out = root / "readme-links-output"
    157     run(BINARY, "--output", out, repo)
    158     metadata = {"full_name": "owner/repo", "default_branch": "main"}
    159     parser = ReadmeLinks(out, repo, metadata)
    160     assert parser.resolve_link("docs/?q=1#top", False) == "https://github.com/owner/repo/tree/main/docs?q=1#top"
    161     assert parser.resolve_link("docs/nested", False) == "https://github.com/owner/repo/tree/main/docs/nested"
    162     assert parser.resolve_link("doc", False) == "doc"
    163     assert parser.resolve_link("absent", False) == "absent"
    164     assert parser.resolve_link("../docs", False) == "../docs"
    165     link_readme_assets(out, repo, metadata)
    166     assert 'href="file/docs/nested/a.txt.html"' in (out / "README.html").read_text()
    167 
    168     # More than a pipe buffer, an empty blob, and repeated requests exercise
    169     # the persistent binary protocol and child process cleanup.
    170     payload = b"\x89PNG\r\n\x1a\n" + b"\0\xffdata\n" * 20000
    171     (repo / "image #?.png").write_bytes(payload)
    172     (repo / "empty.bin").write_bytes(b"")
    173     commit(repo, "Image assets")
    174     image_out = root / "image-output"
    175     image_out.mkdir()
    176     parser = ReadmeLinks(image_out, repo, metadata)
    177     try:
    178         assert parser.resolve_link("image%20%23%3F.png?q=1#image", True) == "raw/image%20%23%3F.png?q=1#image"
    179         process = parser.objects
    180         assert process is not None
    181         assert parser.resolve_link("empty.bin", True) == ""
    182         assert parser.blob(parser.blobs["empty.bin"]) == b""
    183         assert parser.resolve_link("image%20%23%3F.png", True) == "raw/image%20%23%3F.png"
    184         assert parser.objects is process
    185         assert (image_out / "raw/image #?.png").read_bytes() == payload
    186         assert not (image_out / "raw/empty.bin").exists()
    187     finally:
    188         parser.close()
    189     assert process.poll() == 0
    190 
    191     parser = ReadmeLinks(image_out, repo, metadata)
    192     try:
    193         try:
    194             parser.blob("0" * 40)
    195         except RuntimeError:
    196             pass
    197         else:
    198             raise AssertionError("Missing blobs must fail")
    199         failed_process = parser.objects
    200     finally:
    201         parser.close()
    202     assert failed_process.poll() is not None
    203 
    204     parser = ReadmeLinks(image_out, repo, metadata)
    205     parser.blob(parser.blobs["empty.bin"])
    206     stopped_process = parser.objects
    207     stopped_process.kill()
    208     stopped_process.wait()
    209     try:
    210         parser.close()
    211     except RuntimeError:
    212         pass
    213     else:
    214         raise AssertionError("Failed Git processes must be reported")
    215     assert parser.objects is None
    216 
    217 
    218 def main():
    219     with tempfile.TemporaryDirectory(prefix="sqzer-stagit-") as directory:
    220         root = Path(directory)
    221         repo = root / "fixture"
    222         repo.mkdir()
    223         run("git", "init", "-q", "--initial-branch=main", repo)
    224         run("git", "-C", repo, "config", "user.name", "Tester & team")
    225         run("git", "-C", repo, "config", "user.email", "test@example.invalid")
    226         (repo / ".git/description").write_text("A <test> & repository\n")
    227         (repo / ".git/owner").write_text("debuman\n")
    228         (repo / ".git/url").write_text("https://example.invalid/fixture.git\n")
    229         (repo / "README.md").write_text("# Hello\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n\n<details><summary>More</summary>yes</details>\n")
    230         (repo / "LICENSE").write_text("Fixture license\n")
    231         (repo / "gone.txt").write_text("removed later\n")
    232         (repo / "src").mkdir()
    233         (repo / "src/한 글#?.txt").write_text("<tag> & value\nlast line")
    234         (repo / "binary.dat").write_bytes(b"\0\xffbinary")
    235         run("git", "-C", repo, "add", ".")
    236         env = dict(os.environ, GIT_AUTHOR_DATE="2026-01-01T00:00:00+0000",
    237                    GIT_COMMITTER_DATE="2026-01-01T00:00:00+0000")
    238         run("git", "-C", repo, "commit", "-qm", "Initial <&> commit", env=env)
    239         run("git", "-C", repo, "tag", "-am", "Release", "v1", env=env)
    240         (repo / "gone.txt").unlink()
    241         (repo / "src/한 글#?.txt").write_text("updated\n")
    242         run("git", "-C", repo, "add", "-A")
    243         env.update(GIT_AUTHOR_DATE="2026-02-01T00:00:00+0000",
    244                    GIT_COMMITTER_DATE="2026-02-01T00:00:00+0000")
    245         run("git", "-C", repo, "commit", "-qm", "Second commit", env=env)
    246         run("git", "-C", repo, "branch", "topic")
    247         out = root / "output"
    248         run(BINARY, "-l", "1", "-u", "https://git.example/fixture/",
    249             "--output", out, repo)
    250         log = (out / "log.html").read_text()
    251         assert "1 more commits remaining" in log
    252         assert len(list((out / "commit").glob("*.html"))) == 2
    253         assert 'href="commit/' in log
    254         readme = (out / "README.html").read_text()
    255         assert "<h1>Hello</h1>" in readme and "<table>" in readme
    256         assert 'type="checkbox"' in readme and "<details>" in readme
    257         file_page = (out / "file/src/한 글#?.txt.html").read_text()
    258         assert 'href="../../README.html"' in file_page and 'id="l1"' in file_page
    259         assert "Binary file." in (out / "file/binary.dat.html").read_text()
    260         refs = (out / "refs.html").read_text()
    261         assert "topic" in refs and "v1" in refs and "Tester &amp; team" in refs
    262         for filename in ("atom.xml", "tags.xml"):
    263             ET.parse(out / filename)
    264         assert_local_links(out)
    265         empty = root / "empty"
    266         run("git", "init", "-q", "--bare", empty)
    267         empty_out = root / "empty-output"
    268         run(BINARY, "--output", empty_out, empty)
    269         assert (empty_out / "log.html").is_file()
    270         assert not (empty_out / "README.html").exists()
    271         for filename in ("atom.xml", "tags.xml"):
    272             assert ET.parse(empty_out / filename).getroot().find(
    273                 "{http://www.w3.org/2005/Atom}updated") is not None
    274         index = run(BINARY, "--index", empty, repo).decode()
    275         assert index.index(">fixture</a>") < index.index(">empty</a>")
    276         assert "A &lt;test&gt; &amp; repository" in index
    277         failed = subprocess.run([str(BINARY), str(root / "missing")], capture_output=True)
    278         assert failed.returncode != 0
    279         regressions(root, env)
    280         print("Passed: Markdown, history limit, commits, Unicode paths, deleted files, binary files, refs, Atom feeds, empty repository, index order, missing input, exact renames/copies, README names/order, unmerged release tags and commit links, empty/mode-only/merge diffs, cached navigation, README links, streamed diff boundaries, batched image bytes and child cleanup.")
    281 
    282 
    283 if __name__ == "__main__":
    284     main()