"""Tests for .github/convert_html_docs.py — pdoc3 HTML to markdown converter.""" from __future__ import annotations import importlib import sys from pathlib import Path import pytest pytestmark = pytest.mark.docs # The module lives at .github/convert_html_docs.py which isn't a regular # package, so we load it by file path. _SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".github" / "convert_html_docs.py" _spec = importlib.util.spec_from_file_location("convert_html_docs", _SCRIPT_PATH) assert _spec and _spec.loader _mod = importlib.util.module_from_spec(_spec) sys.modules["convert_html_docs"] = _mod _spec.loader.exec_module(_mod) ContentFingerprint = _mod.ContentFingerprint PageConverter = _mod.PageConverter convert_directory = _mod.convert_directory extract_fingerprint = _mod.extract_fingerprint map_link = _mod.map_link map_output_path = _mod.map_output_path validate_links = _mod.validate_links validate_markdown = _mod.validate_markdown MINIMAL_MODULE_HTML = """\

Module livekit.agents.vad

""" NAMESPACE_HTML = """\

Namespace livekit

Sub-modules

livekit.agents

LiveKit Agents for Python

livekit.rtc
""" FUNCTION_HTML = """\

Module livekit.agents

LiveKit Agents for Python

Functions

def get_job_context() ‑ livekit.agents.job.JobContext
Expand source code
def get_job_context() -> JobContext:
    ctx = _JobContextVar.get(None)
    if ctx is None:
        raise RuntimeError(
            "no job context found"
        )
    return ctx

Get the current job context.

""" CLASS_HTML = """\

Module livekit.agents.vad

Classes

class VAD (*, capabilities: VADCapabilities)
Expand source code
class VAD(ABC):
    def __init__(self, *, capabilities: VADCapabilities) -> None:
        self._capabilities = capabilities

Base class for voice activity detection.

Ancestors

  • abc.ABC

Subclasses

  • livekit.plugins.silero.vad.VAD

Instance variables

prop capabilities : VADCapabilities
Expand source code
@property
def capabilities(self) -> VADCapabilities:
    return self._capabilities

Methods

def stream(self) ‑ VADStream
Expand source code
@abstractmethod
def stream(self) -> VADStream: ...

Inherited members

""" CLASS_VARIABLES_HTML = """\

Module livekit.agents

Classes

class APIError (message: str, *, body: object | None = None)

Raised when an API request failed.

Class variables

var message : str

The error message returned by the API.

var body : object | None

The API response body.

""" MULTILINE_SIG_HTML = """\

Module livekit.agents

Functions

def create_api_error(message: str = '',
*,
status: int,
request_id: str | None = None) ‑ APIStatusError

Create an error from HTTP status.

""" ADMONITION_HTML = """\

Module livekit.agents

Functions

def my_func()

Some description.

Note

This is an important note.

""" class TestMapOutputPath: def test_index_html(self, tmp_path: Path) -> None: """dir/index.html -> dir.md""" input_dir = tmp_path / "html" input_dir.mkdir() html_path = input_dir / "livekit" / "agents" / "index.html" html_path.parent.mkdir(parents=True) html_path.touch() result = map_output_path(html_path, input_dir) assert result == Path("livekit/agents.md") def test_regular_html(self, tmp_path: Path) -> None: """dir/file.html -> dir/file.md""" input_dir = tmp_path / "html" input_dir.mkdir() html_path = input_dir / "livekit" / "agents" / "vad.html" html_path.parent.mkdir(parents=True) html_path.touch() result = map_output_path(html_path, input_dir) assert result == Path("livekit/agents/vad.md") def test_top_level_index(self, tmp_path: Path) -> None: """index.html -> index.md""" input_dir = tmp_path / "html" input_dir.mkdir() html_path = input_dir / "index.html" html_path.touch() result = map_output_path(html_path, input_dir) assert result == Path("index.md") def test_nested_index(self, tmp_path: Path) -> None: """livekit/plugins/openai/index.html -> livekit/plugins/openai.md""" input_dir = tmp_path / "html" input_dir.mkdir() html_path = input_dir / "livekit" / "plugins" / "openai" / "index.html" html_path.parent.mkdir(parents=True) html_path.touch() result = map_output_path(html_path, input_dir) assert result == Path("livekit/plugins/openai.md") def test_single_dir_index(self, tmp_path: Path) -> None: """livekit/index.html -> livekit.md""" input_dir = tmp_path / "html" input_dir.mkdir() html_path = input_dir / "livekit" / "index.html" html_path.parent.mkdir(parents=True) html_path.touch() result = map_output_path(html_path, input_dir) assert result == Path("livekit.md") def test_rejects_path_traversal(self, tmp_path: Path) -> None: """Paths with .. are rejected.""" input_dir = tmp_path / "html" input_dir.mkdir() # Create a path that would have .. in relative parts # We can't easily create a path with .. in parts using Path, # so we test the validation logic directly html_path = input_dir / "livekit" / "agents" / "vad.html" html_path.parent.mkdir(parents=True) html_path.touch() # Normal path should work map_output_path(html_path, input_dir) # should not raise def test_no_collision_index_vs_file(self, tmp_path: Path) -> None: """Verify no collision between dir/index.html and dir.html outputs.""" input_dir = tmp_path / "html" input_dir.mkdir() # livekit/agents/index.html -> livekit/agents.md p1 = input_dir / "livekit" / "agents" / "index.html" p1.parent.mkdir(parents=True) p1.touch() # livekit/agents.html -> livekit/agents.md (would collide!) # But pdoc3 never generates both p2 = input_dir / "livekit" / "agents.html" p2.touch() r1 = map_output_path(p1, input_dir) r2 = map_output_path(p2, input_dir) # In the real bucket, this collision never occurs. # Both map to the same path, which is fine since pdoc3 uses one or the other. assert r1 == Path("livekit/agents.md") assert r2 == Path("livekit/agents.md") class TestMapLink: def test_html_to_md(self) -> None: assert map_link("vad.html") == "vad.md" def test_index_html(self) -> None: assert map_link("agents/index.html") == "agents.md" def test_with_fragment(self) -> None: assert map_link("llm.html#ChatContext") == "llm.md#ChatContext" def test_absolute_url_unchanged(self) -> None: assert map_link("https://docs.livekit.io") == "https://docs.livekit.io" def test_fragment_only_unchanged(self) -> None: assert map_link("#section") == "#section" def test_non_html_unchanged(self) -> None: assert map_link("data.json") == "data.json" def test_parent_index(self) -> None: assert map_link("../agents/index.html") == "../agents.md" def test_bare_index_html(self) -> None: """index.html alone (no directory prefix).""" result = map_link("index.html") assert result == "../index.md" class TestPageConverter: def test_minimal_module(self) -> None: """Empty module produces just a heading.""" md = PageConverter(MINIMAL_MODULE_HTML).convert() assert md.startswith("# Module `livekit.agents.vad`") # Should not have any section headings assert "## " not in md def test_namespace_title(self) -> None: """Namespace prefix is preserved.""" md = PageConverter(NAMESPACE_HTML).convert() assert md.startswith("# Namespace `livekit`") def test_submodules(self) -> None: """Sub-modules section renders as bullet list.""" md = PageConverter(NAMESPACE_HTML).convert() assert "## Sub-modules" in md assert "[`livekit.agents`]" in md assert "LiveKit Agents for Python" in md # Module without description should still be listed assert "[`livekit.rtc`]" in md def test_function_basic(self) -> None: """Function with source and description.""" md = PageConverter(FUNCTION_HTML).convert() assert "## Functions" in md assert "### `get_job_context`" in md assert "def get_job_context" in md assert "Get the current job context." in md # Source should be in details assert "
Source" in md assert "def get_job_context() -> JobContext:" in md def test_function_source_html_unescape(self) -> None: """HTML entities in source code are unescaped.""" md = PageConverter(FUNCTION_HTML).convert() # " should become " assert '"no job context found"' in md # > should become > assert "-> JobContext:" in md def test_multiline_signature(self) -> None: """Multi-line signatures with
tags.""" md = PageConverter(MULTILINE_SIG_HTML).convert() assert "### `create_api_error`" in md assert "def create_api_error" in md # Non-breaking hyphen ‑ (U+2011) should be normalized to - assert "- APIStatusError" in md def test_class_basic(self) -> None: """Class with ancestors, subclasses, variables, methods.""" md = PageConverter(CLASS_HTML).convert() assert "## Classes" in md assert "### `VAD`" in md assert "class VAD" in md assert "Base class for voice activity detection." in md def test_class_ancestors(self) -> None: md = PageConverter(CLASS_HTML).convert() assert "#### Ancestors" in md assert "- abc.ABC" in md def test_class_subclasses(self) -> None: md = PageConverter(CLASS_HTML).convert() assert "#### Subclasses" in md assert "- livekit.plugins.silero.vad.VAD" in md def test_class_instance_variables(self) -> None: md = PageConverter(CLASS_HTML).convert() assert "#### Instance variables" in md assert "`capabilities`" in md # Property source should be included assert "@property" in md def test_class_methods(self) -> None: md = PageConverter(CLASS_HTML).convert() assert "#### Methods" in md assert "`stream`" in md assert "@abstractmethod" in md def test_class_inherited_members(self) -> None: md = PageConverter(CLASS_HTML).convert() assert "#### Inherited members" in md assert "**EventEmitter**" in md assert "emit" in md assert "on" in md def test_class_variables(self) -> None: """Class variables section renders properly.""" md = PageConverter(CLASS_VARIABLES_HTML).convert() assert "#### Class variables" in md assert "`message`" in md assert "The error message returned by the API." in md assert "`body`" in md def test_admonition(self) -> None: """Admonitions convert to blockquotes.""" md = PageConverter(ADMONITION_HTML).convert() assert "> **Note:**" in md assert "important note" in md def test_class_source_details(self) -> None: """Class source code appears in
.""" md = PageConverter(CLASS_HTML).convert() # The class source should be in a details block assert "
Source" in md assert "class VAD(ABC):" in md def test_intro_section(self) -> None: """Section intro text is included.""" md = PageConverter(FUNCTION_HTML).convert() assert "LiveKit Agents for Python" in md class TestExtractFingerprint: def test_identifiers(self) -> None: fp = extract_fingerprint(FUNCTION_HTML) assert "livekit.agents.get_job_context" in fp.identifiers def test_source_blocks(self) -> None: fp = extract_fingerprint(FUNCTION_HTML) assert len(fp.source_blocks) > 0 assert any("get_job_context" in s for s in fp.source_blocks) def test_signatures(self) -> None: fp = extract_fingerprint(FUNCTION_HTML) assert "get_job_context" in fp.signatures def test_descriptions(self) -> None: fp = extract_fingerprint(FUNCTION_HTML) assert any("job context" in d.lower() for d in fp.description_texts) def test_class_identifiers(self) -> None: fp = extract_fingerprint(CLASS_HTML) assert "livekit.agents.vad.VAD" in fp.identifiers assert "livekit.agents.vad.VAD.stream" in fp.identifiers assert "livekit.agents.vad.VAD.capabilities" in fp.identifiers def test_empty_module(self) -> None: fp = extract_fingerprint(MINIMAL_MODULE_HTML) assert fp.identifiers == [] assert fp.source_blocks == [] assert fp.signatures == [] class TestValidation: def test_valid_markdown(self) -> None: """Converted markdown from same HTML should validate.""" md = PageConverter(FUNCTION_HTML).convert() fp = extract_fingerprint(FUNCTION_HTML) vr = validate_markdown(fp, md) assert vr.is_valid assert vr.score == 1.0 def test_class_validates(self) -> None: """Complex class HTML validates after conversion.""" md = PageConverter(CLASS_HTML).convert() fp = extract_fingerprint(CLASS_HTML) vr = validate_markdown(fp, md) assert vr.is_valid assert vr.score >= 0.9 def test_missing_identifier_fails(self) -> None: """Missing identifiers cause validation failure.""" fp = ContentFingerprint( identifiers=["livekit.agents.missing_func"], source_blocks=[], signatures=[], description_texts=[], ) vr = validate_markdown(fp, "# Some markdown without the identifier") assert not vr.is_valid assert "livekit.agents.missing_func" in vr.missing_identifiers def test_missing_signature_fails(self) -> None: """Missing signatures cause validation failure (gate).""" fp = ContentFingerprint( identifiers=[], source_blocks=[], signatures=["nonexistent_function"], description_texts=[], ) vr = validate_markdown(fp, "# No such function here") assert not vr.is_valid assert "nonexistent_function" in vr.missing_signatures def test_description_warning_only(self) -> None: """Low description overlap produces warnings but does NOT fail validation.""" fp = ContentFingerprint( identifiers=[], source_blocks=[], signatures=[], description_texts=[ "This is a very specific description about quantum computing algorithms" ], ) vr = validate_markdown(fp, "# Completely unrelated content about cooking recipes") # Should still be valid (descriptions are warning-only) assert vr.is_valid assert len(vr.description_warnings) > 0 def test_empty_fingerprint_valid(self) -> None: """Empty fingerprint (empty module) always validates.""" fp = ContentFingerprint() vr = validate_markdown(fp, "# Module `empty`\n") assert vr.is_valid assert vr.score == 1.0 def test_class_variables_validate(self) -> None: md = PageConverter(CLASS_VARIABLES_HTML).convert() fp = extract_fingerprint(CLASS_VARIABLES_HTML) vr = validate_markdown(fp, md) assert vr.is_valid def test_multiline_sig_validates(self) -> None: md = PageConverter(MULTILINE_SIG_HTML).convert() fp = extract_fingerprint(MULTILINE_SIG_HTML) vr = validate_markdown(fp, md) assert vr.is_valid REAL_HTML_DIR = Path(__file__).parent.parent / "docs" @pytest.mark.skipif( not REAL_HTML_DIR.is_dir(), reason="docs directory not present", ) class TestRealFiles: """Integration tests against real pdoc3 HTML output.""" def test_livekit_index(self) -> None: html_path = REAL_HTML_DIR / "livekit" / "index.html" if not html_path.exists(): pytest.skip("livekit/index.html not found") html_content = html_path.read_text(encoding="utf-8") md = PageConverter(html_content).convert() fp = extract_fingerprint(html_content) vr = validate_markdown(fp, md) assert md.startswith("# Namespace `livekit`") or md.startswith("# Module `livekit`") assert "Sub-modules" in md assert vr.is_valid or vr.score >= 0.9, ( f"Validation: {vr.score:.2f}, missing ids: {vr.missing_identifiers[:3]}" ) def test_agents_index(self) -> None: html_path = REAL_HTML_DIR / "livekit" / "agents" / "index.html" if not html_path.exists(): pytest.skip("livekit/agents/index.html not found") html_content = html_path.read_text(encoding="utf-8") md = PageConverter(html_content).convert() fp = extract_fingerprint(html_content) vr = validate_markdown(fp, md) assert "## Functions" in md assert "## Classes" in md assert vr.is_valid or vr.score >= 0.9, ( f"Validation: {vr.score:.2f}, " f"missing ids: {vr.missing_identifiers[:3]}, " f"missing sigs: {vr.missing_signatures[:3]}" ) def test_vad_module(self) -> None: html_path = REAL_HTML_DIR / "livekit" / "agents" / "vad.html" if not html_path.exists(): pytest.skip("livekit/agents/vad.html not found") html_content = html_path.read_text(encoding="utf-8") md = PageConverter(html_content).convert() fp = extract_fingerprint(html_content) vr = validate_markdown(fp, md) assert "VAD" in md assert vr.is_valid or vr.score >= 0.9 def test_path_mapping_no_collisions(self) -> None: """Verify zero collisions across all HTML files in docs.""" html_files = sorted(REAL_HTML_DIR.rglob("*.html")) assert len(html_files) > 0, "No HTML files found" output_paths: dict[str, Path] = {} collisions: list[tuple[Path, Path, str]] = [] for html_path in html_files: try: out = map_output_path(html_path, REAL_HTML_DIR) except ValueError: continue out_str = str(out) if out_str in output_paths: collisions.append((output_paths[out_str], html_path, out_str)) else: output_paths[out_str] = html_path assert not collisions, f"Path collisions found: {collisions}" def test_batch_validation_pass_rate(self) -> None: """At least 90% of real HTML files should validate after conversion.""" html_files = sorted(REAL_HTML_DIR.rglob("*.html")) if len(html_files) < 10: pytest.skip("Not enough HTML files for batch test") total = 0 passed = 0 for html_path in html_files: html_content = html_path.read_text(encoding="utf-8") md = PageConverter(html_content).convert() fp = extract_fingerprint(html_content) vr = validate_markdown(fp, md) total += 1 if vr.is_valid: passed += 1 rate = passed / total if total > 0 else 0 assert rate >= 0.90, f"Pass rate {rate:.1%} ({passed}/{total}) below 90% threshold" def test_link_integrity(self, tmp_path: Path) -> None: """All internal .md links in converted output must resolve to existing files.""" output_dir = tmp_path / "md-out" convert_directory( REAL_HTML_DIR, output_dir, validate=False, verbose=False, ) result = validate_links(output_dir) assert not result.broken, f"{len(result.broken)} broken links found:\n" + "\n".join( f" {src} -> {href}" for src, href in result.broken[:20] ) # External links (e.g. Pydantic docstring cross-refs) should be # classified separately, not silently dropped. if result.external: # Sanity: we know the Pydantic ../concepts/models.md links exist ext_hrefs = {href for _, href in result.external} assert "../concepts/models.md" in ext_hrefs class TestValidateLinks: def test_no_broken_links(self, tmp_path: Path) -> None: """All links resolve correctly.""" (tmp_path / "a.md").write_text("[link](b.md)\n") (tmp_path / "b.md").write_text("# B\n") result = validate_links(tmp_path) assert result.broken == [] assert result.external == [] def test_broken_link_detected(self, tmp_path: Path) -> None: """A link to a missing file is detected.""" (tmp_path / "a.md").write_text("[link](missing.md)\n") result = validate_links(tmp_path) assert len(result.broken) == 1 assert result.broken[0][1] == "missing.md" def test_external_links_ignored(self, tmp_path: Path) -> None: """External URLs and anchors are not checked.""" (tmp_path / "a.md").write_text( "[ext](https://example.com) [anchor](#foo) [mailto](mailto:a@b.com)\n" ) result = validate_links(tmp_path) assert result.broken == [] assert result.external == [] def test_subdirectory_links(self, tmp_path: Path) -> None: """Links into subdirectories resolve correctly.""" sub = tmp_path / "sub" sub.mkdir() (tmp_path / "a.md").write_text("[link](sub/b.md)\n") (sub / "b.md").write_text("# B\n") result = validate_links(tmp_path) assert result.broken == [] def test_fragment_links(self, tmp_path: Path) -> None: """Links with fragments still check the file exists.""" (tmp_path / "a.md").write_text("[link](b.md#section)\n") (tmp_path / "b.md").write_text("# B\n") result = validate_links(tmp_path) assert result.broken == [] def test_known_external_href_classified(self, tmp_path: Path) -> None: """The exact known Pydantic href is classified as external when missing.""" # Nest source so ../concepts/models.md resolves inside the output tree # but points to a non-existent file. sub = tmp_path / "sub" sub.mkdir() (sub / "a.md").write_text("[Models](../concepts/models.md)\n") result = validate_links(tmp_path) assert result.broken == [] assert len(result.external) == 1 assert result.external[0][1] == "../concepts/models.md" def test_known_external_href_valid_if_exists(self, tmp_path: Path) -> None: """If a known-external href actually resolves to an existing file, it's valid.""" sub = tmp_path / "sub" sub.mkdir() concepts = tmp_path / "concepts" concepts.mkdir() (concepts / "models.md").write_text("# Models\n") (sub / "a.md").write_text("[Models](../concepts/models.md)\n") result = validate_links(tmp_path) assert result.broken == [] assert result.external == [] def test_unknown_missing_dir_is_broken(self, tmp_path: Path) -> None: """Links to non-existent paths that don't match known externals are broken.""" (tmp_path / "a.md").write_text("[link](nonexistent_dir/page.md)\n") result = validate_links(tmp_path) assert len(result.broken) == 1 assert result.broken[0][1] == "nonexistent_dir/page.md" assert result.external == [] def test_escape_above_root_is_broken(self, tmp_path: Path) -> None: """Links resolving above the output root are broken, not external.""" (tmp_path / "a.md").write_text("[oops](../../oops.md)\n") result = validate_links(tmp_path) assert len(result.broken) == 1 assert result.broken[0][1] == "../../oops.md" assert result.external == [] def test_similar_but_not_exact_external_is_broken(self, tmp_path: Path) -> None: """A href similar to the known external but not exact is still broken.""" (tmp_path / "a.md").write_text("[link](../concepts/other.md)\n") result = validate_links(tmp_path) assert len(result.broken) == 1 assert result.external == []