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 "<source> & 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 & 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 <test> & 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()