" in str(visitor.root.label)
+
+ def test_nested_tree(self) -> None:
+ child = BlankNode()
+ parent = ListNode([child])
+
+ visitor = _StrVisitor()
+ parent.visit(visitor)
+
+ assert visitor.root is not None
+
+
+class TestDocument:
+ def test_init_with_root(self) -> None:
+ root = BlankNode()
+ doc = Document(root)
+ assert doc.root is root
+
+ def test_extension_returns_none_when_no_output_nodes(self) -> None:
+ root = BlankNode()
+ doc = Document(root)
+ assert doc.extension() is None
+
+ def test_extension_returns_extension_from_output_node(self) -> None:
+ from io import TextIOBase
+
+ from docc.context import Context
+
+ class TestOutputNode(OutputNode):
+ @property
+ def children(self):
+ return ()
+
+ def replace_child(self, old: Node, new: Node) -> None:
+ pass
+
+ @property
+ def extension(self) -> str:
+ return ".test"
+
+ def output(
+ self, context: Context, destination: TextIOBase
+ ) -> None:
+ pass
+
+ root = TestOutputNode()
+ doc = Document(root)
+ assert doc.extension() == ".test"
+
+
+class TestExtensionVisitor:
+ def test_finds_extension(self) -> None:
+ from io import TextIOBase
+
+ from docc.context import Context
+
+ class TestOutputNode(OutputNode):
+ @property
+ def children(self):
+ return ()
+
+ def replace_child(self, old: Node, new: Node) -> None:
+ pass
+
+ @property
+ def extension(self) -> str:
+ return ".html"
+
+ def output(
+ self, context: Context, destination: TextIOBase
+ ) -> None:
+ pass
+
+ root = TestOutputNode()
+ visitor = _ExtensionVisitor()
+ root.visit(visitor)
+
+ assert visitor.extension == ".html"
+
+ def test_returns_none_when_no_output_nodes(self) -> None:
+ root = BlankNode()
+ visitor = _ExtensionVisitor()
+ root.visit(visitor)
+
+ assert visitor.extension is None
+
+ def test_conflicting_extensions_logs_warning(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """
+ When two OutputNodes have different extensions, the visitor
+ logs a warning and keeps the first extension.
+ """
+ import logging
+ from io import TextIOBase
+
+ from docc.context import Context
+
+ class HtmlOutputNode(OutputNode):
+ @property
+ def children(self):
+ return ()
+
+ def replace_child(self, old: Node, new: Node) -> None:
+ pass
+
+ @property
+ def extension(self) -> str:
+ return ".html"
+
+ def output(
+ self, context: Context, destination: TextIOBase
+ ) -> None:
+ pass
+
+ class TxtOutputNode(OutputNode):
+ @property
+ def children(self):
+ return ()
+
+ def replace_child(self, old: Node, new: Node) -> None:
+ pass
+
+ @property
+ def extension(self) -> str:
+ return ".txt"
+
+ def output(
+ self, context: Context, destination: TextIOBase
+ ) -> None:
+ pass
+
+ root = ListNode([HtmlOutputNode(), TxtOutputNode()])
+ visitor = _ExtensionVisitor()
+
+ with caplog.at_level(logging.WARNING):
+ root.visit(visitor)
+
+ # The first extension is kept
+ assert visitor.extension == ".html"
+ # A warning was logged about the conflict
+ assert any("extension" in r.message for r in caplog.records)
+
+
+class ConditionalSkipVisitor(RecordingVisitor):
+ skip_after_first: bool
+
+ def __init__(self, skip_after_first: bool = False) -> None:
+ super().__init__()
+ self.skip_after_first = skip_after_first
+ self._first_seen = False
+
+ @override
+ def enter(self, node: Node) -> Visit:
+ self.events.append(("enter", id(node)))
+ if self.skip_after_first and not self._first_seen:
+ self._first_seen = True
+ return Visit.TraverseChildren
+ elif self.skip_after_first:
+ return Visit.SkipChildren
+ return Visit.TraverseChildren
+
+
+class TestVisitorEdgeCases:
+ def test_visit_empty_list_node(self) -> None:
+ node = ListNode([])
+ visitor = RecordingVisitor()
+ node.visit(visitor)
+
+ assert visitor.events == [
+ ("enter", id(node)),
+ ("exit", id(node)),
+ ]
+
+ def test_visit_deeply_nested(self) -> None:
+ node: Node = BlankNode()
+ for _ in range(10):
+ node = ListNode([node])
+
+ visitor = RecordingVisitor()
+ node.visit(visitor)
+
+ enter_count = sum(1 for e in visitor.events if e[0] == "enter")
+ assert enter_count == 11
+
+ def test_visit_wide_tree(self) -> None:
+ children: List[Node] = [BlankNode() for _ in range(100)]
+ node = ListNode(children)
+
+ visitor = RecordingVisitor()
+ node.visit(visitor)
+
+ enter_count = sum(
+ 1
+ for e in visitor.events
+ if e[0] == "enter" and e != ("enter", id(node))
+ )
+ assert enter_count == 100
+
+ def test_conditional_skip(self) -> None:
+ grandchild = BlankNode()
+ child = ListNode([grandchild])
+ parent = ListNode([child])
+
+ visitor = ConditionalSkipVisitor(skip_after_first=True)
+ parent.visit(visitor)
+
+ assert ("enter", id(grandchild)) not in visitor.events
+
+
+class ModifyingVisitor(Visitor):
+ def __init__(self, old: Node, new: Node) -> None:
+ self.old = old
+ self.new = new
+ self.stack: List[Node] = []
+
+ @override
+ def enter(self, node: Node) -> Visit:
+ self.stack.append(node)
+ return Visit.TraverseChildren
+
+ @override
+ def exit(self, node: Node) -> None:
+ self.stack.pop()
+ if node == self.old and self.stack:
+ self.stack[-1].replace_child(self.old, self.new)
+
+
+class SkipSpecificChildVisitor(Visitor):
+ """Visitor that returns SkipChildren for a specific child node."""
+
+ enter_calls: List[Node]
+ exit_calls: List[Node]
+ skip_target: Node
+
+ def __init__(self, skip_target: Node) -> None:
+ self.enter_calls = []
+ self.exit_calls = []
+ self.skip_target = skip_target
+
+ @override
+ def enter(self, node: Node) -> Visit:
+ self.enter_calls.append(node)
+ if node is self.skip_target:
+ return Visit.SkipChildren
+ return Visit.TraverseChildren
+
+ @override
+ def exit(self, node: Node) -> None:
+ self.exit_calls.append(node)
+
+
+def test_visit_skip_children_calls_exit() -> None:
+ grandchild = BlankNode()
+ skipped_child = ListNode([grandchild])
+ other_child = BlankNode()
+ parent = ListNode([skipped_child, other_child])
+
+ visitor = SkipSpecificChildVisitor(skip_target=skipped_child)
+ parent.visit(visitor)
+
+ # exit must be called for the child that returned SkipChildren
+ assert skipped_child in visitor.exit_calls
+
+ # The grandchild should NOT have been entered (children were skipped)
+ assert grandchild not in visitor.enter_calls
+
+ # Both the skipped child and the other child should have exit called
+ assert other_child in visitor.exit_calls
+ assert parent in visitor.exit_calls
+
+
+def test_visit_replace_during_exit() -> None:
+ old_child = BlankNode()
+ new_child = BlankNode()
+ parent = ListNode([old_child])
+
+ visitor = ModifyingVisitor(old_child, new_child)
+ parent.visit(visitor)
+
+ assert old_child not in parent.children
+ assert new_child in parent.children
diff --git a/tests/test_files.py b/tests/test_files.py
new file mode 100644
index 0000000..9ad7737
--- /dev/null
+++ b/tests/test_files.py
@@ -0,0 +1,227 @@
+# Copyright (C) 2026 Ethereum Foundation
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from io import StringIO
+from pathlib import Path, PurePath
+from typing import Dict, Optional, Set
+
+import pytest
+
+from docc.context import Context
+from docc.document import BlankNode, Document
+from docc.plugins.files import (
+ FileNode,
+ FilesBuilder,
+ FilesDiscover,
+ FileSource,
+)
+from docc.settings import PluginSettings, Settings
+from docc.source import Source
+
+
+@pytest.fixture
+def plugin_settings(tmp_path: Path) -> PluginSettings:
+ settings = Settings(tmp_path, {"tool": {"docc": {}}})
+ return settings.for_plugin("docc.files.discover")
+
+
+class TestFileSource:
+ def test_init(self) -> None:
+ relative = PurePath("src/file.txt")
+ absolute = PurePath("/path/to/src/file.txt")
+ source = FileSource(relative, absolute)
+
+ assert source.relative_path == relative
+ assert source.absolute_path == absolute
+
+ def test_output_path_removes_suffix(self) -> None:
+ relative = PurePath("docs/readme.md")
+ absolute = PurePath("/path/docs/readme.md")
+ source = FileSource(relative, absolute)
+
+ assert source.output_path == PurePath("docs/readme")
+
+ def test_output_path_compound_extension(self) -> None:
+ relative = PurePath("static/chota.min.css")
+ absolute = PurePath("/path/static/chota.min.css")
+ source = FileSource(relative, absolute)
+
+ assert source.output_path == PurePath("static/chota.min")
+
+ def test_output_path_no_suffix(self) -> None:
+ relative = PurePath("docs/readme")
+ absolute = PurePath("/path/docs/readme")
+ source = FileSource(relative, absolute)
+
+ assert source.output_path == PurePath("docs/readme")
+
+
+class TestFileNode:
+ def test_init(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("content")
+
+ node = FileNode(file_path)
+ assert node.path == file_path
+
+ def test_children_empty(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("content")
+
+ node = FileNode(file_path)
+ assert node.children == ()
+
+ def test_replace_child_raises(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("content")
+
+ node = FileNode(file_path)
+ with pytest.raises(TypeError):
+ node.replace_child(BlankNode(), BlankNode())
+
+ def test_extension(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("content")
+
+ node = FileNode(file_path)
+ assert node.extension == ".txt"
+
+ def test_extension_multiple_suffixes(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "chota.min.css"
+ file_path.write_text("content")
+
+ node = FileNode(file_path)
+ assert node.extension == ".css"
+
+ def test_output(self, tmp_path: Path) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("file content here")
+
+ node = FileNode(file_path)
+ context = Context({})
+ destination = StringIO()
+
+ node.output(context, destination)
+
+ assert destination.getvalue() == "file content here"
+
+
+class TestFilesBuilder:
+ def test_build_processes_file_sources(
+ self, tmp_path: Path, plugin_settings: PluginSettings
+ ) -> None:
+ file_path = tmp_path / "test.txt"
+ file_path.write_text("content")
+
+ source = FileSource(PurePath("test.txt"), file_path)
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = FilesBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert source in processed
+ assert len(processed) == 1
+ assert len(unprocessed) == 0
+ assert isinstance(processed[source].root, FileNode)
+
+ def test_build_ignores_non_file_sources(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ class OtherSource(Source):
+ @property
+ def relative_path(self) -> Optional[PurePath]:
+ return PurePath("other.py")
+
+ @property
+ def output_path(self) -> PurePath:
+ return PurePath("other.py")
+
+ source = OtherSource()
+ unprocessed: Set[Source] = {source}
+ processed: Dict[Source, Document] = {}
+
+ builder = FilesBuilder(plugin_settings)
+ builder.build(unprocessed, processed)
+
+ assert source in unprocessed
+ assert len(processed) == 0
+
+
+class TestFilesDiscover:
+ def test_init_no_files(self, tmp_path: Path) -> None:
+ settings = Settings(tmp_path, {"tool": {"docc": {}}})
+ plugin_settings = settings.for_plugin("docc.files.discover")
+
+ discover = FilesDiscover(plugin_settings)
+ assert discover.sources == []
+
+ def test_init_with_files(self, tmp_path: Path) -> None:
+ first_file = tmp_path / "file1.txt"
+ first_file.write_text("content1")
+ second_file = tmp_path / "file2.txt"
+ second_file.write_text("content2")
+
+ settings = Settings(
+ tmp_path,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.files.discover": {
+ "files": ["file1.txt", "file2.txt"]
+ }
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.files.discover")
+
+ discover = FilesDiscover(plugin_settings)
+ assert len(discover.sources) == 2
+
+ def test_discover_yields_sources(self, tmp_path: Path) -> None:
+ first_file = tmp_path / "file1.txt"
+ first_file.write_text("content")
+
+ settings = Settings(
+ tmp_path,
+ {
+ "tool": {
+ "docc": {
+ "plugins": {
+ "docc.files.discover": {"files": ["file1.txt"]}
+ }
+ }
+ }
+ },
+ )
+ plugin_settings = settings.for_plugin("docc.files.discover")
+
+ discover = FilesDiscover(plugin_settings)
+ sources = list(discover.discover(frozenset()))
+
+ assert len(sources) == 1
+ assert isinstance(sources[0], FileSource)
+
+ def test_discover_empty_when_no_files(self, tmp_path: Path) -> None:
+ settings = Settings(tmp_path, {"tool": {"docc": {}}})
+ plugin_settings = settings.for_plugin("docc.files.discover")
+
+ discover = FilesDiscover(plugin_settings)
+ sources = list(discover.discover(frozenset()))
+
+ assert sources == []
diff --git a/tests/test_html_e2e.py b/tests/test_html_e2e.py
new file mode 100644
index 0000000..91be245
--- /dev/null
+++ b/tests/test_html_e2e.py
@@ -0,0 +1,482 @@
+# Copyright (C) 2026 Ethereum Foundation
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+End-to-end tests for the HTML rendering pipeline.
+
+These tests exercise the full docc pipeline path through HTML rendering:
+discover -> build -> context -> transform -> HTML render -> file output.
+"""
+
+from io import StringIO
+from pathlib import PurePath
+from typing import Optional
+
+from docc.context import Context
+from docc.document import BlankNode, Document, ListNode
+from docc.plugins.html import (
+ HTML,
+ HTMLRoot,
+ HTMLTag,
+ HTMLTransform,
+ TextNode,
+ blank_node,
+ html_tag,
+ list_node,
+ references_definition,
+ references_reference,
+ render_reference,
+ text_node,
+)
+from docc.plugins.references import (
+ Definition,
+ Index,
+ IndexTransform,
+ Reference,
+)
+from docc.settings import PluginSettings
+from docc.source import Source, StringSource
+
+
+class _MockSource(StringSource):
+ """A minimal Source implementation for testing."""
+
+ def __init__(
+ self,
+ output_path: Optional[PurePath] = None,
+ relative_path: Optional[PurePath] = None,
+ ) -> None:
+ if output_path is None:
+ output_path = PurePath("test.py")
+ if relative_path is None:
+ relative_path = output_path
+
+ super().__init__("", output_path, relative_path)
+
+
+class TestHTMLTransformProducesHTMLRoot:
+ """
+ Test that HTMLTransform replaces a document tree with an HTMLRoot.
+
+ This exercises the pipeline step where the document tree (containing
+ Definition, BlankNode, ListNode, etc.) is converted into an HTML tree
+ via HTMLVisitor and the entry-point-based renderer lookup.
+ """
+
+ def test_definition_with_blank_becomes_html_root(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ """
+ A document containing a Definition wrapping a BlankNode is
+ transformed into an HTMLRoot.
+
+ The Definition renderer (references_definition) internally creates
+ its own HTMLVisitor to render children, so this exercises the
+ nested visitor path through the entry point system.
+ """
+ source = _MockSource(PurePath("docs/module.py"))
+ index = Index()
+
+ definition = Definition(
+ identifier="my_module.MyClass", child=BlankNode()
+ )
+
+ document = Document(definition)
+ context = Context({Document: document, Source: source, Index: index})
+
+ # First apply IndexTransform to assign specifiers.
+ index_transform = IndexTransform(plugin_settings)
+ index_transform.transform(context)
+ assert definition.specifier == 0
+
+ # Then apply HTMLTransform.
+ html_transform = HTMLTransform(plugin_settings)
+ html_transform.transform(context)
+
+ assert isinstance(document.root, HTMLRoot)
+
+ def test_transform_skips_already_output_node(
+ self, plugin_settings: PluginSettings
+ ) -> None:
+ """
+ If the document root is already an OutputNode (e.g. HTMLRoot),
+ HTMLTransform leaves it unchanged.
+ """
+ context = Context({})
+ existing_root = HTMLRoot(context)
+ existing_root.append(HTMLTag("div"))
+ document = Document(existing_root)
+ context = Context({Document: document})
+
+ transform = HTMLTransform(plugin_settings)
+ transform.transform(context)
+
+ assert document.root is existing_root
+
+
+class TestHTMLRootOutputProducesValidHTML:
+ """
+ Test that HTMLRoot.output() renders a full HTML document string
+ containing the expected structural elements from the Jinja2 template.
+ """
+
+ def test_output_includes_static_css_links(self) -> None:
+ """The rendered HTML includes links to chota and docc CSS."""
+ source = _MockSource(PurePath("docs/page.py"))
+ context = Context({Source: source})
+ root = HTMLRoot(context)
+ root.append(HTMLTag("p"))
+
+ buffer = StringIO()
+ root.output(context, buffer)
+ output = buffer.getvalue()
+
+ assert "chota.min.css" in output
+ assert "docc.css" in output
+
+ def test_output_with_extra_css(self) -> None:
+ """Extra CSS files configured via HTML context appear in output."""
+ source = _MockSource(PurePath("index.py"))
+ html_config = HTML(extra_css=["custom.css"], breadcrumbs=True)
+ context = Context({Source: source, HTML: html_config})
+ root = HTMLRoot(context)
+ root.append(HTMLTag("p"))
+
+ buffer = StringIO()
+ root.output(context, buffer)
+ output = buffer.getvalue()
+
+ assert "custom.css" in output
+
+ def test_output_breadcrumbs_for_nested_path(self) -> None:
+ """Breadcrumbs are rendered for documents in nested directories."""
+ source = _MockSource(PurePath("a/b/c/page.py"))
+ context = Context({Source: source})
+ root = HTMLRoot(context)
+ root.append(HTMLTag("section"))
+
+ buffer = StringIO()
+ root.output(context, buffer)
+ output = buffer.getvalue()
+
+ assert "breadcrumbs" in output
+ assert "page.py" in output
+
+ def test_output_no_breadcrumbs_when_disabled(self) -> None:
+ """Breadcrumbs section is absent when breadcrumbs=False."""
+ source = _MockSource(PurePath("a/b/page.py"))
+ html_config = HTML(extra_css=[], breadcrumbs=False)
+ context = Context({Source: source, HTML: html_config})
+ root = HTMLRoot(context)
+ root.append(HTMLTag("div"))
+
+ buffer = StringIO()
+ root.output(context, buffer)
+ output = buffer.getvalue()
+
+ assert "breadcrumbs" not in output
+
+ def test_output_nested_html_tags_produce_markup(self) -> None:
+ """Nested HTMLTag structures are correctly serialized."""
+ source = _MockSource(PurePath("page.py"))
+ context = Context({Source: source})
+ root = HTMLRoot(context)
+
+ section = HTMLTag("section", {"id": "main"})
+ heading = HTMLTag("h1")
+ heading.append(TextNode("Title"))
+ section.append(heading)
+ paragraph = HTMLTag("p")
+ paragraph.append(TextNode("Body text"))
+ section.append(paragraph)
+ root.append(section)
+
+ buffer = StringIO()
+ root.output(context, buffer)
+ output = buffer.getvalue()
+
+ assert "