mirror of
https://github.com/zensical/zensical.git
synced 2026-08-25 00:26:43 +00:00
refactor: report invalid autorefs under invalid_links validation setting
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
+323
-1
@@ -23,20 +23,51 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import zensical
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from io import TextIOWrapper
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _wait_for(
|
||||
condition: Callable[[], bool],
|
||||
process: subprocess.Popen[str],
|
||||
output: Callable[[], str],
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
"""Wait for a serve-process condition or report its captured output."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return
|
||||
if process.poll() is not None:
|
||||
raise AssertionError(
|
||||
f"Serve process exited with {process.returncode}:\n{output()}"
|
||||
)
|
||||
time.sleep(0.02)
|
||||
raise AssertionError(f"Serve process timed out:\n{output()}")
|
||||
|
||||
|
||||
def _collect_output(stream: TextIOWrapper, lines: list[str]) -> None:
|
||||
"""Collect process output without blocking its pipes."""
|
||||
lines.extend(stream)
|
||||
|
||||
|
||||
def test_validation_reports_issues_after_rendering(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Validation reports source and autoref issues after page rendering."""
|
||||
"""Validation reports source and link issues after page rendering."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text(
|
||||
@@ -74,3 +105,294 @@ unresolved_references = true
|
||||
captured = capfd.readouterr()
|
||||
assert "No issues found" in captured.err
|
||||
assert "2 issues found" not in captured.err
|
||||
|
||||
|
||||
def test_validation_reports_unresolved_autorefs(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Autorefs that fail to resolve are reported as invalid links."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text(
|
||||
"# Hello\n\n[normal](missing.md)\n\n[autoref][missing-id]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(docs / "other.md").write_text("# Other\n", encoding="utf-8")
|
||||
config = tmp_path / "zensical.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[project]
|
||||
site_name = "Test"
|
||||
|
||||
[project.plugins.autorefs]
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
zensical.build(str(config), {"clean": True, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "page does not exist" in captured.err
|
||||
assert "unresolved autoref" in captured.err
|
||||
assert "2 issues found" in captured.err
|
||||
assert captured.err.count("2 issues found") == 1
|
||||
|
||||
(docs / "index.md").write_text("# Hello\n", encoding="utf-8")
|
||||
zensical.build(str(config), {"clean": True, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "No issues found" in captured.err
|
||||
assert "2 issues found" not in captured.err
|
||||
|
||||
|
||||
def test_validation_reports_references_resolved_by_autorefs(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The deprecated unresolved_references setting keeps its original
|
||||
behavior, reporting references even when autorefs resolve them."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text(
|
||||
"# Home\n\n[Target][target-heading]\n", encoding="utf-8"
|
||||
)
|
||||
(docs / "other.md").write_text(
|
||||
"# Other\n\n## target-heading\n", encoding="utf-8"
|
||||
)
|
||||
config = tmp_path / "zensical.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[project]
|
||||
site_name = "Test"
|
||||
|
||||
[project.plugins.autorefs]
|
||||
|
||||
[project.validation]
|
||||
unresolved_references = true
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
zensical.build(str(config), {"clean": True, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "unresolved link reference" in captured.err
|
||||
assert "unresolved autoref" not in captured.err
|
||||
assert "1 issue found" in captured.err
|
||||
output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'href="other/#target-heading"' in output
|
||||
|
||||
|
||||
def test_validation_refreshes_cached_autoref_resolutions(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Validation refreshes autorefs when targets are added and removed."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
identifier = "validation-target-only-available-after-rebuild"
|
||||
(docs / "index.md").write_text(
|
||||
f"# Home\n\n[Target][{identifier}]\n", encoding="utf-8"
|
||||
)
|
||||
target = docs / "other.md"
|
||||
target.write_text("# Other\n", encoding="utf-8")
|
||||
stable_identifier = "validation-stable-target"
|
||||
stable_marker = "validation-stable-cache-sentinel"
|
||||
(docs / "stable.md").write_text(
|
||||
(
|
||||
f"# Stable\n\n[{stable_marker}][{stable_identifier}]\n\n"
|
||||
f"## {stable_identifier}\n"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = tmp_path / "zensical.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[project]
|
||||
site_name = "Test"
|
||||
|
||||
[project.plugins.autorefs]
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
zensical.build(str(config), {"clean": True, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "unresolved autoref" in captured.err
|
||||
marker = stable_marker.encode()
|
||||
|
||||
def stable_cache_entries() -> dict[str, bytes]:
|
||||
cache_dir = tmp_path / ".cache"
|
||||
return {
|
||||
path.name: data
|
||||
for path in cache_dir.iterdir()
|
||||
if path.is_file() and marker in (data := path.read_bytes())
|
||||
}
|
||||
|
||||
stable_cache = stable_cache_entries()
|
||||
assert stable_cache
|
||||
|
||||
target.write_text(f"# Other\n\n## {identifier}\n", encoding="utf-8")
|
||||
zensical.build(str(config), {"clean": False, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "No issues found" in captured.err
|
||||
assert "unresolved autoref" not in captured.err
|
||||
output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
|
||||
assert f'href="other/#{identifier}"' in output
|
||||
assert stable_cache_entries() == stable_cache
|
||||
|
||||
target.write_text("# Other\n", encoding="utf-8")
|
||||
zensical.build(str(config), {"clean": False, "strict": False})
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "unresolved autoref" in captured.err
|
||||
output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
|
||||
assert f'href="other/#{identifier}"' not in output
|
||||
assert stable_cache_entries() == stable_cache
|
||||
|
||||
|
||||
def test_serve_refreshes_autorefs_before_validation(tmp_path: Path) -> None:
|
||||
"""Serving resolves a fixed autoref without reporting a stale snapshot."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
identifier = "serve-target-only-available-after-rebuild"
|
||||
(docs / "index.md").write_text(
|
||||
f"# Home\n\n[Target][{identifier}]\n", encoding="utf-8"
|
||||
)
|
||||
target = docs / "other.md"
|
||||
target.write_text("# Other\n", encoding="utf-8")
|
||||
config = tmp_path / "zensical.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[project]
|
||||
site_name = "Test"
|
||||
dev_addr = "127.0.0.1:0"
|
||||
|
||||
[project.plugins.autorefs]
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"zensical",
|
||||
"serve",
|
||||
"--config-file",
|
||||
str(config),
|
||||
],
|
||||
cwd=tmp_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=_collect_output,
|
||||
args=(process.stdout, stdout),
|
||||
daemon=True,
|
||||
),
|
||||
threading.Thread(
|
||||
target=_collect_output,
|
||||
args=(process.stderr, stderr),
|
||||
daemon=True,
|
||||
),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
def output() -> str:
|
||||
return "".join(stdout + stderr)
|
||||
|
||||
try:
|
||||
_wait_for(lambda: "1 issue found" in "".join(stderr), process, output)
|
||||
offset = len(stderr)
|
||||
|
||||
target.write_text(f"# Other\n\n## {identifier}\n", encoding="utf-8")
|
||||
_wait_for(
|
||||
lambda: "No issues found" in "".join(stderr[offset:]),
|
||||
process,
|
||||
output,
|
||||
)
|
||||
|
||||
rebuild_output = "".join(stderr[offset:])
|
||||
assert "unresolved autoref" not in rebuild_output
|
||||
rendered = tmp_path / "site" / "index.html"
|
||||
_wait_for(
|
||||
lambda: (
|
||||
rendered.exists()
|
||||
and f'href="other/#{identifier}"'
|
||||
in rendered.read_text(encoding="utf-8")
|
||||
),
|
||||
process,
|
||||
output,
|
||||
)
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
for thread in threads:
|
||||
thread.join(timeout=1)
|
||||
|
||||
|
||||
def test_cached_template_autorefs_refresh_with_targets(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Autorefs introduced by cached templates use current target data."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n", encoding="utf-8")
|
||||
target = docs / "other.md"
|
||||
target.write_text("# Other\n", encoding="utf-8")
|
||||
|
||||
identifier = "template-autoref-target"
|
||||
overrides = tmp_path / "overrides"
|
||||
overrides.mkdir()
|
||||
(overrides / "main.html").write_text(
|
||||
(
|
||||
"<main>{{ page.content }}</main>"
|
||||
f'<autoref identifier="{identifier}">Target</autoref>'
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = tmp_path / "zensical.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[project]
|
||||
site_name = "Test"
|
||||
|
||||
[project.theme]
|
||||
custom_dir = "overrides"
|
||||
|
||||
[project.plugins.autorefs]
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
zensical.build(str(config), {"clean": True, "strict": False})
|
||||
output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
|
||||
assert f'href="other/#{identifier}"' not in output
|
||||
|
||||
# Template autorefs cannot be located in the Markdown source, so they
|
||||
# are reported as page-level issues without a source location
|
||||
captured = capfd.readouterr()
|
||||
assert f"unresolved autoref `{identifier}` in index.md" in captured.err
|
||||
assert f"unresolved autoref `{identifier}` in other.md" in captured.err
|
||||
assert "2 issues found" in captured.err
|
||||
|
||||
target.write_text(f"# Other\n\n## {identifier}\n", encoding="utf-8")
|
||||
zensical.build(str(config), {"clean": False, "strict": False})
|
||||
output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
|
||||
assert f'href="other/#{identifier}"' in output
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "No issues found" in captured.err
|
||||
|
||||
Reference in New Issue
Block a user