feat: add deterministic preview rendering
This commit is contained in:
parent
8c75f4f44d
commit
411f417670
23 changed files with 1413 additions and 142 deletions
12
tests/fixtures/alpha/.docforge/project.toml
vendored
12
tests/fixtures/alpha/.docforge/project.toml
vendored
|
|
@ -19,6 +19,18 @@ id = "alpha-editor"
|
|||
families = ["guide", "proof"]
|
||||
operations = ["create", "update", "move", "delete"]
|
||||
|
||||
[render]
|
||||
template_root = "docs/templates"
|
||||
preview_root = ".docforge/previews"
|
||||
|
||||
[[render.views]]
|
||||
id = "manual"
|
||||
renderer = "generic_html"
|
||||
template = "manual.html"
|
||||
output = ".docforge/rendered/manual.html"
|
||||
title = "Alpha Manual"
|
||||
families = ["guide", "proof"]
|
||||
|
||||
[graph]
|
||||
allowed_relations = ["depends_on", "proves", "supersedes", "relates_to", "returns_to"]
|
||||
|
||||
|
|
|
|||
12
tests/fixtures/alpha/docs/templates/manual.html
vendored
Normal file
12
tests/fixtures/alpha/docs/templates/manual.html
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="docforge-render" content="{{ docforge_render_identity }}">
|
||||
<title>{{ docforge_title }}</title>
|
||||
</head>
|
||||
<body data-project="{{ docforge_project_id }}" data-view="{{ docforge_view_id }}">
|
||||
<header><h1>{{ docforge_title }}</h1></header>
|
||||
<main>{{ docforge_content }}</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -35,7 +35,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
names = tuple(tool.name for tool in response.tools)
|
||||
self.assertEqual(ALL_TOOLS, names)
|
||||
self.assertEqual(9, len(PROPOSAL_TOOLS))
|
||||
self.assertEqual(10, len(PROPOSAL_TOOLS))
|
||||
self.assertFalse(
|
||||
any(
|
||||
token in name
|
||||
|
|
@ -79,8 +79,11 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertFalse(contract["canonical_writes_allowed"])
|
||||
self.assertFalse(contract["project_switching_allowed"])
|
||||
self.assertIn("canonical_writes", contract["excluded_operations"])
|
||||
self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"])
|
||||
self.assertFalse(contract["isolated_changeset_writes_allowed"])
|
||||
self.assertFalse(contract["proposal_access"]["enabled"])
|
||||
self.assertTrue(results[10].structuredContent["configured"])
|
||||
self.assertEqual("stale", results[10].structuredContent["state"])
|
||||
context = results[8].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
|
|
@ -224,6 +227,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
diff = await session.call_tool(
|
||||
"docforge_get_changeset_diff", {"changeset_id": "mcp-update"}
|
||||
)
|
||||
preview = await session.call_tool(
|
||||
"docforge_preview_changeset",
|
||||
{"changeset_id": "mcp-update", "view_id": "manual"},
|
||||
)
|
||||
undeclared = await session.call_tool(
|
||||
"docforge_preview_changeset",
|
||||
{"changeset_id": "mcp-update", "view_id": "not-declared"},
|
||||
)
|
||||
|
||||
self.assertTrue(contract.structuredContent["proposal_access"]["enabled"])
|
||||
self.assertEqual(
|
||||
|
|
@ -245,6 +256,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
["update", "create", "move", "delete"],
|
||||
[change["operation"] for change in diff.structuredContent["changes"]],
|
||||
)
|
||||
self.assertEqual("current", preview.structuredContent["state"])
|
||||
self.assertEqual(
|
||||
".docforge/previews/mcp-update/manual.html",
|
||||
preview.structuredContent["preview"]["path"],
|
||||
)
|
||||
self.assertTrue(preview.structuredContent["preview_identity"])
|
||||
self.assertEqual("error", undeclared.structuredContent["status"])
|
||||
self.assertEqual("unknown_render_view", undeclared.structuredContent["error"]["code"])
|
||||
canonical_after = {
|
||||
path.relative_to(root).as_posix(): path.read_bytes()
|
||||
for path in (root / "docs/content").glob("*")
|
||||
|
|
@ -253,6 +272,8 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(canonical_before, canonical_after)
|
||||
self.assertFalse((root / "docs/content/mcp-node.md").exists())
|
||||
self.assertTrue((root / ".docforge/changesets/mcp-update.json").is_file())
|
||||
self.assertTrue((root / ".docforge/previews/mcp-update/manual.html").is_file())
|
||||
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
|
||||
|
||||
async def test_server_without_writer_rejects_proposal_mutation_structurally(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
338
tests/test_rendering.py
Normal file
338
tests/test_rendering.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue