339 lines
16 KiB
Python
339 lines
16 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import contextlib
|
||
|
|
import hashlib
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import shutil
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
from docforge.changesets import ChangesetStore
|
||
|
|
from docforge.cli import main
|
||
|
|
from docforge.errors import DocForgeError
|
||
|
|
from docforge.project import Project
|
||
|
|
from docforge.rendering import RenderService
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
FIXTURES = ROOT / "tests" / "fixtures"
|
||
|
|
|
||
|
|
|
||
|
|
class DocForgeRenderingTests(unittest.TestCase):
|
||
|
|
def copy_fixture(self, name: str, destination: Path) -> Path:
|
||
|
|
root = destination / name
|
||
|
|
shutil.copytree(FIXTURES / name, root)
|
||
|
|
return root
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def project_content_hash(root: Path) -> str:
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
paths = [
|
||
|
|
root / ".docforge/project.toml",
|
||
|
|
root / "POLICY.md",
|
||
|
|
*(root / "docs/content").glob("*"),
|
||
|
|
*(root / "docs/templates").glob("*"),
|
||
|
|
]
|
||
|
|
for path in sorted((path for path in paths if path.is_file()), key=lambda item: str(item)):
|
||
|
|
digest.update(path.relative_to(root).as_posix().encode("utf-8"))
|
||
|
|
digest.update(path.read_bytes())
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def node_hash(project: Project, node_id: str) -> str:
|
||
|
|
return next(node.content_hash for node in project.load().nodes if node.node_id == node_id)
|
||
|
|
|
||
|
|
def test_declared_render_is_repeatable_and_status_detects_stale_output(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
root = self.copy_fixture("alpha", Path(directory))
|
||
|
|
project = Project.open(root)
|
||
|
|
service = RenderService(project)
|
||
|
|
missing = service.status()
|
||
|
|
self.assertTrue(missing["configured"])
|
||
|
|
self.assertEqual("stale", missing["state"])
|
||
|
|
self.assertEqual("missing", missing["outputs"][0]["state"])
|
||
|
|
|
||
|
|
first = service.render("manual")
|
||
|
|
output = root / ".docforge/rendered/manual.html"
|
||
|
|
first_bytes = output.read_bytes()
|
||
|
|
second = service.render("manual")
|
||
|
|
self.assertEqual(
|
||
|
|
first["output"]["render_identity"], second["output"]["render_identity"]
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
first["output"]["actual_output_hash"], second["output"]["actual_output_hash"]
|
||
|
|
)
|
||
|
|
self.assertEqual(first_bytes, output.read_bytes())
|
||
|
|
self.assertEqual("current", service.status("manual")["state"])
|
||
|
|
|
||
|
|
workflow = root / "docs/content/workflow.md"
|
||
|
|
workflow.write_text(
|
||
|
|
workflow.read_text(encoding="utf-8") + "\nA new canonical sentence.\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
stale = service.status("manual")
|
||
|
|
self.assertEqual("stale", stale["state"])
|
||
|
|
self.assertEqual("stale", stale["outputs"][0]["state"])
|
||
|
|
self.assertEqual(first_bytes, output.read_bytes())
|
||
|
|
|
||
|
|
def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
root = self.copy_fixture("alpha", Path(directory))
|
||
|
|
project = Project.open(root)
|
||
|
|
changesets = ChangesetStore(project, "alpha-editor")
|
||
|
|
service = RenderService(project, changesets)
|
||
|
|
canonical_before = self.project_content_hash(root)
|
||
|
|
canonical_render = service.render("manual")
|
||
|
|
committed_output = root / ".docforge/rendered/manual.html"
|
||
|
|
committed_before = committed_output.read_bytes()
|
||
|
|
|
||
|
|
created = changesets.create("user-preview")
|
||
|
|
proposed = changesets.propose_update(
|
||
|
|
changeset_id="user-preview",
|
||
|
|
expected_changeset_hash=created["changeset_hash"],
|
||
|
|
node_id="guide.workflow",
|
||
|
|
expected_content_hash=self.node_hash(project, "guide.workflow"),
|
||
|
|
metadata={"summary": "A summary visible only in the preview."},
|
||
|
|
content="<script>alert('unsafe')</script>\n\n**Rendered safely.**",
|
||
|
|
relationship_changes=[],
|
||
|
|
rationale="Show the proposed content through the declared view.",
|
||
|
|
)
|
||
|
|
first = service.preview("user-preview", "manual")
|
||
|
|
preview_path = root / ".docforge/previews/user-preview/manual.html"
|
||
|
|
preview_bytes = preview_path.read_bytes()
|
||
|
|
second = service.preview("user-preview", "manual")
|
||
|
|
|
||
|
|
self.assertEqual(proposed["changeset_hash"], first["changeset_hash"])
|
||
|
|
self.assertEqual(first["preview_identity"], second["preview_identity"])
|
||
|
|
self.assertEqual(preview_bytes, preview_path.read_bytes())
|
||
|
|
self.assertNotEqual(
|
||
|
|
canonical_render["output"]["render_identity"], first["preview_identity"]
|
||
|
|
)
|
||
|
|
html = preview_bytes.decode("utf-8")
|
||
|
|
self.assertIn("<script>", html)
|
||
|
|
self.assertNotIn("<script>", html)
|
||
|
|
self.assertIn("<strong>Rendered safely.</strong>", html)
|
||
|
|
self.assertIn("A summary visible only in the preview.", html)
|
||
|
|
self.assertEqual(
|
||
|
|
".docforge/previews/user-preview/manual.html", first["preview"]["path"]
|
||
|
|
)
|
||
|
|
self.assertEqual(canonical_before, self.project_content_hash(root))
|
||
|
|
self.assertEqual(committed_before, committed_output.read_bytes())
|
||
|
|
self.assertEqual("current", service.status("manual")["state"])
|
||
|
|
|
||
|
|
def test_failed_and_mid_input_renders_preserve_previous_outputs(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
root = self.copy_fixture("alpha", Path(directory))
|
||
|
|
project = Project.open(root)
|
||
|
|
service = RenderService(project)
|
||
|
|
service.render("manual")
|
||
|
|
output = root / ".docforge/rendered/manual.html"
|
||
|
|
before = output.read_bytes()
|
||
|
|
workflow = root / "docs/content/workflow.md"
|
||
|
|
original_verify = service._verify_canonical
|
||
|
|
|
||
|
|
def mutate_before_replace(snapshot, view, template_bytes) -> None:
|
||
|
|
workflow.write_text(
|
||
|
|
workflow.read_text(encoding="utf-8") + "\nChanged during render.\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
original_verify(snapshot, view, template_bytes)
|
||
|
|
|
||
|
|
with (
|
||
|
|
mock.patch.object(service, "_verify_canonical", side_effect=mutate_before_replace),
|
||
|
|
self.assertRaisesRegex(DocForgeError, "changed during rendering") as changed,
|
||
|
|
):
|
||
|
|
service.render("manual")
|
||
|
|
self.assertEqual("render_input_changed", changed.exception.code)
|
||
|
|
self.assertEqual(before, output.read_bytes())
|
||
|
|
self.assertFalse(tuple(output.parent.glob(".docforge-render-*")))
|
||
|
|
|
||
|
|
fresh_root = self.copy_fixture("alpha", Path(directory) / "invalid")
|
||
|
|
fresh_project = Project.open(fresh_root)
|
||
|
|
fresh_service = RenderService(fresh_project)
|
||
|
|
fresh_service.render("manual")
|
||
|
|
fresh_output = fresh_root / ".docforge/rendered/manual.html"
|
||
|
|
fresh_before = fresh_output.read_bytes()
|
||
|
|
template = fresh_root / "docs/templates/manual.html"
|
||
|
|
template.write_text("<html>{{ unsupported }}</html>", encoding="utf-8")
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "unsupported tokens"):
|
||
|
|
fresh_service.render("manual")
|
||
|
|
self.assertEqual(fresh_before, fresh_output.read_bytes())
|
||
|
|
|
||
|
|
def test_render_configuration_paths_commands_views_and_limits_fail_closed(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
parent = Path(directory)
|
||
|
|
|
||
|
|
command_root = self.copy_fixture("alpha", parent / "command")
|
||
|
|
command_descriptor = command_root / ".docforge/project.toml"
|
||
|
|
marker = parent / "command-ran"
|
||
|
|
command_descriptor.write_text(
|
||
|
|
command_descriptor.read_text(encoding="utf-8").replace(
|
||
|
|
'title = "Alpha Manual"\nfamilies = ["guide", "proof"]',
|
||
|
|
'title = "Alpha Manual"\n'
|
||
|
|
'families = ["guide", "proof"]\n'
|
||
|
|
f'command = "touch {marker.as_posix()}"',
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
|
||
|
|
Project.open(command_root)
|
||
|
|
self.assertFalse(marker.exists())
|
||
|
|
|
||
|
|
renderer_root = self.copy_fixture("alpha", parent / "renderer")
|
||
|
|
renderer_descriptor = renderer_root / ".docforge/project.toml"
|
||
|
|
renderer_descriptor.write_text(
|
||
|
|
renderer_descriptor.read_text(encoding="utf-8").replace(
|
||
|
|
'renderer = "generic_html"', 'renderer = "shell"'
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "unsupported built-in renderer"):
|
||
|
|
Project.open(renderer_root)
|
||
|
|
|
||
|
|
output_root = self.copy_fixture("alpha", parent / "output")
|
||
|
|
output_descriptor = output_root / ".docforge/project.toml"
|
||
|
|
output_descriptor.write_text(
|
||
|
|
output_descriptor.read_text(encoding="utf-8").replace(
|
||
|
|
'output = ".docforge/rendered/manual.html"',
|
||
|
|
'output = "docs/content/manual.html"',
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "protected project root"):
|
||
|
|
Project.open(output_root)
|
||
|
|
|
||
|
|
template_root = self.copy_fixture("alpha", parent / "template")
|
||
|
|
template_descriptor = template_root / ".docforge/project.toml"
|
||
|
|
template_descriptor.write_text(
|
||
|
|
template_descriptor.read_text(encoding="utf-8")
|
||
|
|
.replace('template_root = "docs/templates"', 'template_root = "docs/content"')
|
||
|
|
.replace('template = "manual.html"', 'template = "foundation.md"'),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "Template input must not overlap"):
|
||
|
|
Project.open(template_root)
|
||
|
|
|
||
|
|
limit_root = self.copy_fixture("alpha", parent / "limit")
|
||
|
|
limit_descriptor = limit_root / ".docforge/project.toml"
|
||
|
|
limit_descriptor.write_text(
|
||
|
|
limit_descriptor.read_text(encoding="utf-8") + "\nmax_render_bytes = 100\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
|
||
|
|
Project.open(limit_root)
|
||
|
|
limit_descriptor.write_text(
|
||
|
|
limit_descriptor.read_text(encoding="utf-8")
|
||
|
|
.replace("\nmax_render_bytes = 100\n", "")
|
||
|
|
.replace(
|
||
|
|
"max_changeset_bytes = 100000",
|
||
|
|
"max_changeset_bytes = 100000\nmax_render_bytes = 100",
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
limit_service = RenderService(Project.open(limit_root))
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "configured limit") as limit_error:
|
||
|
|
limit_service.render("manual")
|
||
|
|
self.assertEqual("render_too_large", limit_error.exception.code)
|
||
|
|
self.assertFalse((limit_root / ".docforge/rendered/manual.html").exists())
|
||
|
|
|
||
|
|
template_limit_root = self.copy_fixture("alpha", parent / "template-limit")
|
||
|
|
template_limit_descriptor = template_limit_root / ".docforge/project.toml"
|
||
|
|
template_limit_descriptor.write_text(
|
||
|
|
template_limit_descriptor.read_text(encoding="utf-8").replace(
|
||
|
|
"max_changeset_bytes = 100000",
|
||
|
|
"max_changeset_bytes = 100000\nmax_template_bytes = 10",
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
template_limit_service = RenderService(Project.open(template_limit_root))
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "template exceeds") as template_limit:
|
||
|
|
template_limit_service.render("manual")
|
||
|
|
self.assertEqual("template_too_large", template_limit.exception.code)
|
||
|
|
self.assertFalse((template_limit_root / ".docforge/rendered/manual.html").exists())
|
||
|
|
|
||
|
|
safe_root = self.copy_fixture("alpha", parent / "safe")
|
||
|
|
safe_service = RenderService(Project.open(safe_root))
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "not declared") as unknown:
|
||
|
|
safe_service.render("not-a-view")
|
||
|
|
self.assertEqual("unknown_render_view", unknown.exception.code)
|
||
|
|
|
||
|
|
def test_symlink_inputs_and_outputs_are_rejected_and_unconfigured_status_is_explicit(
|
||
|
|
self,
|
||
|
|
) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
parent = Path(directory)
|
||
|
|
root = self.copy_fixture("alpha", parent)
|
||
|
|
project = Project.open(root)
|
||
|
|
service = RenderService(project)
|
||
|
|
outside_template = parent / "outside-template.html"
|
||
|
|
outside_template.write_text("{{ docforge_content }}", encoding="utf-8")
|
||
|
|
template = root / "docs/templates/manual.html"
|
||
|
|
template.unlink()
|
||
|
|
template.symlink_to(outside_template)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "missing or unsafe"):
|
||
|
|
service.render("manual")
|
||
|
|
|
||
|
|
template.unlink()
|
||
|
|
shutil.copy2(FIXTURES / "alpha/docs/templates/manual.html", template)
|
||
|
|
outside_output = parent / "outside-output.html"
|
||
|
|
outside_output.write_text("do not replace", encoding="utf-8")
|
||
|
|
output = root / ".docforge/rendered/manual.html"
|
||
|
|
output.parent.mkdir(parents=True)
|
||
|
|
output.symlink_to(outside_output)
|
||
|
|
with self.assertRaisesRegex(DocForgeError, "output path is unsafe"):
|
||
|
|
service.render("manual")
|
||
|
|
self.assertEqual("do not replace", outside_output.read_text(encoding="utf-8"))
|
||
|
|
|
||
|
|
beta = RenderService(Project.open(FIXTURES / "beta")).status()
|
||
|
|
self.assertFalse(beta["configured"])
|
||
|
|
self.assertEqual("not_configured", beta["state"])
|
||
|
|
self.assertEqual([], beta["outputs"])
|
||
|
|
|
||
|
|
def test_cli_exposes_declared_render_status_and_isolated_preview_only(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
root = self.copy_fixture("alpha", Path(directory))
|
||
|
|
project = Project.open(root)
|
||
|
|
changesets = ChangesetStore(project, "alpha-editor")
|
||
|
|
created = changesets.create("cli-preview")
|
||
|
|
changesets.propose_update(
|
||
|
|
changeset_id="cli-preview",
|
||
|
|
expected_changeset_hash=created["changeset_hash"],
|
||
|
|
node_id="guide.workflow",
|
||
|
|
expected_content_hash=self.node_hash(project, "guide.workflow"),
|
||
|
|
metadata={"summary": "CLI preview summary."},
|
||
|
|
content=None,
|
||
|
|
relationship_changes=[],
|
||
|
|
rationale="Exercise the explicit CLI preview path.",
|
||
|
|
)
|
||
|
|
|
||
|
|
commands = (
|
||
|
|
("render", "manual"),
|
||
|
|
("render-status", "manual"),
|
||
|
|
("preview", "cli-preview", "manual"),
|
||
|
|
)
|
||
|
|
results: list[dict] = []
|
||
|
|
for command in commands:
|
||
|
|
stream = io.StringIO()
|
||
|
|
with contextlib.redirect_stdout(stream):
|
||
|
|
self.assertEqual(0, main(["--project-root", str(root), *command]))
|
||
|
|
results.append(json.loads(stream.getvalue()))
|
||
|
|
self.assertEqual("current", results[0]["state"])
|
||
|
|
self.assertEqual("current", results[1]["state"])
|
||
|
|
self.assertEqual("current", results[2]["state"])
|
||
|
|
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
||
|
|
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())
|
||
|
|
|
||
|
|
stream = io.StringIO()
|
||
|
|
with contextlib.redirect_stdout(stream):
|
||
|
|
self.assertEqual(
|
||
|
|
2,
|
||
|
|
main(["--project-root", str(root), "render", "undeclared"]),
|
||
|
|
)
|
||
|
|
self.assertEqual("unknown_render_view", json.loads(stream.getvalue())["error"]["code"])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|