Files
chemenu/tools/chemenu/tests/test_wiki_mirror.py
T
torbenandClaude Code 90ea0eea1b
CI / verify (push) Successful in 6m22s
CI / pwsh (push) Successful in 2m24s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped
feat(workflow): Gitea-Wiki als generierter Spiegel von docs/ (#192)
Konverter .gitea/scripts/wiki_mirror.py, Push-Skript wiki-push.sh, Job wiki-mirror in release.yml, Dispatch-Workflow wiki-mirror.yml und --check-Schritt in ci.yml. Version 9.0.0-beta.13.

Files changed:
- .gitea/scripts/wiki-push.sh
- .gitea/scripts/wiki_mirror.py
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- .gitea/workflows/wiki-mirror.yml
- CHANGES.md
- README.md
- VERSION
- docs/dev/development.md
- tools/chemenu/tests/test_wiki_mirror.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-07 23:10:19 +02:00

314 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the Gitea wiki mirror: the converter `.gitea/scripts/wiki_mirror.py` and the push
script `.gitea/scripts/wiki-push.sh`. Neither is part of `tools/chemenu`; the converter is loaded
by path and the push script is run against a bare repository on disk."""
from __future__ import annotations
import importlib.util
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
SCRIPTS = REPO / ".gitea" / "scripts"
TAG_URL = "https://gitea.nehmer.net/torben/chemenu/src/tag/v1.2.3"
def _load():
spec = importlib.util.spec_from_file_location("wiki_mirror", SCRIPTS / "wiki_mirror.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
wm = _load()
def _write(root: Path, files: dict[str, str]) -> Path:
for rel, text in files.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return root
def _run(source: Path, out: Path | None, *extra: str) -> int:
argv = ["--source", str(source), "--version", "1.2.3", *extra]
if out is not None:
argv += ["--out", str(out)]
return wm.main(argv)
def _read_all(out: Path) -> dict[str, bytes]:
return {p.name: p.read_bytes() for p in sorted(out.iterdir())}
MINI = {
"README.md": "# Home\n\n[guide](docs/how-to/ingest.md) [agents](AGENTS.md)\n",
"INSTALL.md": "# Install\n",
"AGENTS.md": "# Agents\n",
"tools/CONTRACT.md": "# Contract\n",
"docs/how-to/README.md": "# How-to\n",
"docs/how-to/ingest.md": "# Ingest\n\n[up](README.md) [ref](../reference/commands/pages.md#foo)\n",
"docs/reference/commands/README.md": "# Commands\n",
"docs/reference/commands/pages.md": "# Pages\n\n## Foo\n",
"docs/dev/development.md": "# Developing\n",
}
@pytest.mark.parametrize(
"path, name",
[
("docs/how-to/ingest-a-source.md", "How-to-Ingest-a-source"),
("docs/how-to/README.md", "How-to"),
("docs/reference/commands/pages.md", "Reference-Commands-Pages"),
("docs/reference/commands/README.md", "Reference-Commands"),
("docs/dev/development.md", "Dev-Development"),
("docs/why-gates-are-code.md", "Why-gates-are-code"),
("README.md", "Home"),
("INSTALL.md", "Install"),
],
)
def test_name_rule(tmp_path, path, name):
_write(tmp_path, {"README.md": "x\n", "INSTALL.md": "x\n", path: "x\n"})
pages, errors = wm.collect_pages(str(tmp_path))
assert errors == []
assert {p.path: p.name for p in pages}[path] == name
def test_every_file_under_docs_gets_a_unique_name_and_output_is_reproducible(tmp_path):
pages, errors = wm.collect_pages(str(REPO))
assert errors == []
mirrored = {p.path for p in pages}
on_disk = {p.relative_to(REPO).as_posix() for p in (REPO / "docs").rglob("*.md")}
assert on_disk <= mirrored
names = [p.name.lower() for p in pages]
assert len(names) == len(set(names))
first, second = tmp_path / "a", tmp_path / "b"
assert _run(REPO, first) == 0
assert _run(REPO, second) == 0
assert _read_all(first) == _read_all(second)
def test_every_relative_link_in_the_output_is_a_page_or_a_tag_url(tmp_path):
out = tmp_path / "out"
assert _run(REPO, out) == 0
names = {p.stem for p in out.iterdir()}
for page in out.iterdir():
for line, in_code in wm.code_flags(page.read_text(encoding="utf-8")):
if in_code:
continue
for match in wm._LINK.finditer(wm.mask_code_spans(line)):
target = match.group(1)
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target) or target.startswith("#"):
continue
assert target.partition("#")[0] in names, (page.name, target)
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
linked = set(re.findall(r"\]\(([^)#]+)\)", sidebar))
assert linked <= names
def test_links_are_rewritten_and_everything_else_is_left_alone(tmp_path):
out = tmp_path / "out"
assert _run(_write(tmp_path / "src", MINI), out) == 0
ingest = (out / "How-to-Ingest.md").read_text(encoding="utf-8")
assert "[up](How-to)" in ingest
assert "[ref](Reference-Commands-Pages#foo)" in ingest
home = (out / "Home.md").read_text(encoding="utf-8")
assert "[guide](How-to-Ingest)" in home
assert f"[agents]({TAG_URL}/AGENTS.md)" in home
assert home.startswith(
f"*Generated from [`README.md`]({TAG_URL}/README.md) at v1.2.3 – edit the repository, not this wiki.*\n\n"
)
assert sorted(_read_all(out)) == sorted(
[
"Home.md", "Install.md", "How-to.md", "How-to-Ingest.md", "Reference-Commands.md",
"Reference-Commands-Pages.md", "Dev-Development.md", "_Sidebar.md",
]
)
def test_external_links_anchors_and_code_are_not_touched(tmp_path):
body = (
"[ext](https://example.org/a.md) [mail](mailto:a@b.c) [here](#foo)\n"
"`[inline](missing.md)` and [`code text`](AGENTS.md)\n"
"```\n[fenced](missing.md)\n```\n"
"~~~sh\n[tilde](missing.md)\n~~~\n"
" ````\n```\n[nested](missing.md)\n````\n"
)
src = _write(tmp_path / "src", {"README.md": body, "AGENTS.md": "x\n"})
out = tmp_path / "out"
assert _run(src, out) == 0
home = (out / "Home.md").read_text(encoding="utf-8").split("\n\n", 1)[1]
assert home == body.replace("[`code text`](AGENTS.md)", f"[`code text`]({TAG_URL}/AGENTS.md)")
def test_link_to_a_directory_without_a_readme_points_at_the_tag(tmp_path):
src = _write(tmp_path / "src", {"README.md": "[d](docs/) [t](tools/)\n", "tools/x.txt": "x\n", "docs/a.md": "x\n"})
out = tmp_path / "out"
assert _run(src, out) == 0
home = (out / "Home.md").read_text(encoding="utf-8")
assert f"[d]({TAG_URL}/docs)" in home and f"[t]({TAG_URL}/tools)" in home
@pytest.mark.parametrize(
"files, expect",
[
({"docs/a/b.md": "x\n", "docs/a-b.md": "x\n"}, ["docs/a/b.md", "docs/a-b.md"]),
({"docs/how-to/x.md": "x\n", "docs/How-to/x.md": "x\n"}, ["docs/how-to/x.md", "docs/How-to/x.md"]),
({"docs/install.md": "x\n"}, ["docs/install.md", "reserved"]),
({"docs/_sidebar.md": "x\n"}, ["docs/_sidebar.md", "reserved"]),
],
)
def test_a_name_collision_fails_and_names_the_paths(tmp_path, capsys, files, expect):
src = _write(tmp_path / "src", {"README.md": "x\n", "INSTALL.md": "x\n", **files})
assert _run(src, tmp_path / "out") == 1
err = capsys.readouterr().err
for fragment in expect:
assert fragment in err
assert not (tmp_path / "out").exists()
def test_a_link_to_a_missing_file_fails_and_names_both_paths(tmp_path, capsys):
src = _write(tmp_path / "src", {"README.md": "x\n", "docs/a.md": "[gone](../nowhere/x.md)\n"})
assert _run(src, tmp_path / "out") == 1
err = capsys.readouterr().err
assert "docs/a.md" in err and "nowhere/x.md" in err
def test_a_link_out_of_the_tree_fails(tmp_path, capsys):
src = _write(tmp_path / "src", {"README.md": "[x](../../etc/passwd)\n"})
assert _run(src, tmp_path / "out") == 1
assert "leaves the tree" in capsys.readouterr().err
def test_check_writes_nothing_and_fails_on_a_problem(tmp_path):
src = _write(tmp_path / "src", MINI)
assert _run(src, None, "--check") == 0
assert not (tmp_path / "out").exists()
_write(src, {"docs/bad.md": "[x](missing.md)\n"})
assert _run(src, None, "--check") == 1
def test_a_flat_docs_directory_without_areas_is_mirrored(tmp_path):
src = _write(
tmp_path / "src",
{
"README.md": "[why](docs/why.md) [dev](DEVELOPMENT.md)\n",
"DEVELOPMENT.md": "x\n",
"docs/why.md": "# Why\n\n[home](../README.md)\n",
},
)
out = tmp_path / "out"
assert _run(src, out) == 0
assert "[why](Why)" in (out / "Home.md").read_text(encoding="utf-8")
assert f"[dev]({TAG_URL}/DEVELOPMENT.md)" in (out / "Home.md").read_text(encoding="utf-8")
assert "[home](Home)" in (out / "Why.md").read_text(encoding="utf-8")
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
assert "* Documentation\n * [Why](Why)\n" in sidebar
def test_sidebar_order_and_nesting(tmp_path):
out = tmp_path / "out"
assert _run(_write(tmp_path / "src", MINI), out) == 0
assert (out / "_Sidebar.md").read_text(encoding="utf-8") == (
"* [Home](Home)\n"
"* [Install](Install)\n"
"* [How-to](How-to)\n"
" * [Ingest](How-to-Ingest)\n"
"* Reference\n"
" * [Commands](Reference-Commands)\n"
" * [Pages](Reference-Commands-Pages)\n"
"* Dev\n"
" * [Developing](Dev-Development)\n"
)
def test_a_non_empty_output_directory_is_refused(tmp_path, capsys):
out = tmp_path / "out"
_write(out, {"stale.md": "x\n"})
assert _run(_write(tmp_path / "src", MINI), out) == 1
assert "not empty" in capsys.readouterr().err
# --- wiki-push.sh -----------------------------------------------------------------------------
needs_tools = pytest.mark.skipif(
not all(shutil.which(t) for t in ("git", "sh", "base64", "diff", "find")),
reason="needs git and POSIX utilities",
)
def _git(*args: str, cwd: Path | None = None) -> str:
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
class Wiki:
def __init__(self, root: Path):
self.bare = root / "wiki.git"
self.out = root / "out"
subprocess.run(["git", "init", "-q", "--bare", str(self.bare)], check=True)
def mirror(self, files: dict[str, str], version: str = "1.0.0", token: str = "t"):
shutil.rmtree(self.out, ignore_errors=True)
_write(self.out, files)
env = {**os.environ, "WIKI_REMOTE": str(self.bare), "WIKI_TOKEN": token}
if not token:
env.pop("WIKI_TOKEN")
return subprocess.run(
["sh", str(SCRIPTS / "wiki-push.sh"), str(self.out), version],
env=env, capture_output=True, text=True,
)
def commits(self) -> int:
refs = _git("--git-dir", str(self.bare), "for-each-ref")
return int(_git("--git-dir", str(self.bare), "rev-list", "--all", "--count")) if refs else 0
def files(self) -> dict[str, str]:
branch = _git("--git-dir", str(self.bare), "for-each-ref", "--format=%(refname)").split()[0]
names = _git("--git-dir", str(self.bare), "ls-tree", "-r", "--name-only", branch).split()
return {n: _git("--git-dir", str(self.bare), "show", f"{branch}:{n}") for n in names}
@needs_tools
def test_push_fills_an_empty_wiki_with_exactly_the_output(tmp_path):
wiki = Wiki(tmp_path)
result = wiki.mirror({"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"})
assert result.returncode == 0, result.stderr
assert wiki.files() == {"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"}
assert wiki.commits() == 1
@needs_tools
def test_push_of_the_same_output_makes_no_commit(tmp_path):
wiki = Wiki(tmp_path)
files = {"Home.md": "home\n", "A.md": "a\n"}
assert wiki.mirror(files).returncode == 0
again = wiki.mirror(files)
assert again.returncode == 0
assert "Wiki already matches v1.0.0" in again.stdout
assert wiki.commits() == 1
@needs_tools
def test_push_removes_a_page_the_output_no_longer_has_and_updates_the_rest(tmp_path):
wiki = Wiki(tmp_path)
assert wiki.mirror({"Home.md": "one\n", "Gone.md": "x\n", "Keep.md": "k\n"}).returncode == 0
assert wiki.mirror({"Home.md": "two\n", "Keep.md": "k\n"}, version="2.0.0").returncode == 0
assert wiki.files() == {"Home.md": "two\n", "Keep.md": "k\n"}
assert wiki.commits() == 2
assert "Mirror docs at v2.0.0" in _git("--git-dir", str(wiki.bare), "log", "-1", "--format=%s", "--all")
@needs_tools
def test_push_without_a_token_fails_before_touching_the_wiki(tmp_path):
wiki = Wiki(tmp_path)
result = wiki.mirror({"Home.md": "home\n"}, token="")
assert result.returncode != 0
assert "WIKI_TOKEN" in result.stderr
assert wiki.commits() == 0