mirror of
https://github.com/zensical/zensical.git
synced 2026-08-23 15:46:44 +00:00
399 lines
12 KiB
Python
Vendored
399 lines
12 KiB
Python
Vendored
# Copyright (c) 2025-2026 Zensical and contributors
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# All contributions are certified under the DCO
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to
|
|
# deal in the Software without restriction, including without limitation the
|
|
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
# sell copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
# IN THE SOFTWARE.
|
|
|
|
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 link issues after page rendering."""
|
|
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")
|
|
(tmp_path / "watched.md").write_text(
|
|
"# Watched support file\n", encoding="utf-8"
|
|
)
|
|
config = tmp_path / "zensical.toml"
|
|
config.write_text(
|
|
"""
|
|
[project]
|
|
site_name = "Test"
|
|
watch = ["watched.md"]
|
|
|
|
[project.validation]
|
|
unresolved_references = true
|
|
""".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 link reference" 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_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
|