Source code for tests.test_broken_links

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# 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 2
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for the broken links report: linkcheck parsing and the issue verdict."""

from __future__ import annotations

import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

import pytest
from click.testing import CliRunner

from repomatic.broken_links import (
    FIX_LINK_LABEL,
    LYCHEE_INSTALL_FAILURE_EXIT,
    LYCHEE_REPORT_FORMAT,
    NO_BROKEN_LINKS,
    LinkcheckResult,
    LinkVerdict,
    LycheeRun,
    filter_broken,
    generate_markdown_report,
    get_label,
    lychee_section,
    manage_combined_broken_links_issue,
    parse_output_json,
    run_lychee,
    sphinx_section,
)
from repomatic.cli.main import repomatic
from repomatic.markdown import sanitize_markdown_mentions
from repomatic.tooling.registry import TOOL_REGISTRY, ToolInstallError

# ---------------------------------------------------------------------------
# Label selection tests
# ---------------------------------------------------------------------------


[docs] @pytest.mark.parametrize( ("repo", "expected"), ( ("awesome-falsehood", "🩹 fix link"), # The prefix alone is enough, with nothing after it. ("awesome-", "🩹 fix link"), ("workflows", "📚 documentation"), # Containing "awesome" is not the same as starting with it. ("my-awesome-repo", "📚 documentation"), ), ) def test_get_label(repo, expected): """Only a repo whose name starts with `awesome-` gets the fix-link label.""" assert get_label(repo) == expected
# --------------------------------------------------------------------------- # Sphinx linkcheck parsing tests # ---------------------------------------------------------------------------
[docs] def test_parse_output_json_empty_file(tmp_path): """Empty file returns no results.""" output = tmp_path / "output.json" output.write_text("", encoding="UTF-8") assert parse_output_json(output) == []
[docs] def test_parse_output_json_skips_blank_lines(tmp_path): """Blank lines in the file are skipped.""" entry = { "filename": "index.rst", "lineno": 1, "status": "working", "code": 200, "uri": "https://example.com", "info": "", } content = "\n" + json.dumps(entry) + "\n\n" output = tmp_path / "output.json" output.write_text(content, encoding="UTF-8") results = parse_output_json(output) assert len(results) == 1 assert results[0].filename == "index.rst"
[docs] def test_parse_output_json_single_entry(tmp_path): """Single entry is parsed correctly.""" entry = { "filename": "api.rst", "lineno": 42, "status": "broken", "code": 404, "uri": "https://example.com/missing", "info": "404 Not Found", } output = tmp_path / "output.json" output.write_text(json.dumps(entry) + "\n", encoding="UTF-8") results = parse_output_json(output) assert len(results) == 1 assert results[0] == LinkcheckResult( filename="api.rst", lineno=42, status="broken", uri="https://example.com/missing", info="404 Not Found", )
[docs] def test_parse_output_json_multiple_entries(tmp_path): """Multiple entries are parsed correctly.""" entries = [ { "filename": "index.rst", "lineno": 1, "status": "working", "code": 200, "uri": "https://example.com", "info": "", }, { "filename": "api.rst", "lineno": 10, "status": "broken", "code": 404, "uri": "https://example.com/gone", "info": "404 Not Found", }, ] content = "\n".join(json.dumps(e) for e in entries) + "\n" output = tmp_path / "output.json" output.write_text(content, encoding="UTF-8") results = parse_output_json(output) assert len(results) == 2
[docs] def test_parse_output_json_missing_info_field(tmp_path): """Missing info field defaults to empty string.""" entry = { "filename": "index.rst", "lineno": 1, "status": "working", "code": 200, "uri": "https://example.com", } output = tmp_path / "output.json" output.write_text(json.dumps(entry) + "\n", encoding="UTF-8") results = parse_output_json(output) assert results[0].info == ""
# --------------------------------------------------------------------------- # Sphinx linkcheck filtering tests # ---------------------------------------------------------------------------
[docs] @pytest.mark.parametrize( ("status", "expected_count"), [ ("broken", 1), ("timeout", 1), ("working", 0), ("redirected", 0), ("unchecked", 0), ], ) def test_filter_by_status(status, expected_count): """Only broken and timeout statuses are kept.""" results = [ LinkcheckResult( filename="index.rst", lineno=1, status=status, uri="https://example.com", info="", ), ] assert len(filter_broken(results)) == expected_count
[docs] def test_filter_broken_mixed_statuses(): """Only broken and timeout are kept from a mixed list.""" results = [ LinkcheckResult("a.rst", 1, "working", "https://a.com", ""), LinkcheckResult("b.rst", 2, "broken", "https://b.com", "Not Found"), LinkcheckResult("c.rst", 3, "redirected", "https://c.com", ""), LinkcheckResult("d.rst", 4, "timeout", "https://d.com", "Timed out"), LinkcheckResult("e.rst", 5, "unchecked", "https://e.com", ""), ] broken = filter_broken(results) assert len(broken) == 2 assert broken[0].uri == "https://b.com" assert broken[1].uri == "https://d.com"
[docs] def test_filter_broken_empty_input(): """Empty input returns empty list.""" assert filter_broken([]) == []
# --------------------------------------------------------------------------- # Sphinx linkcheck report generation tests # ---------------------------------------------------------------------------
[docs] def test_report_empty_list(): """Empty list returns empty string.""" assert generate_markdown_report([]) == ""
[docs] def test_report_groups_by_file_alphabetically(): """Results are grouped by filename in alphabetical order.""" broken = [ LinkcheckResult("z_file.rst", 1, "broken", "https://z.com", ""), LinkcheckResult("a_file.rst", 5, "broken", "https://a.com", ""), LinkcheckResult("a_file.rst", 2, "broken", "https://a2.com", ""), ] report = generate_markdown_report(broken) a_pos = report.index("## `a_file.rst`") z_pos = report.index("## `z_file.rst`") assert a_pos < z_pos
[docs] def test_report_escapes_pipe_chars_in_info(): """Pipe characters in info are escaped to avoid breaking tables.""" broken = [ LinkcheckResult( filename="index.rst", lineno=1, status="broken", uri="https://example.com", info="Error | Details", ), ] report = generate_markdown_report(broken) assert "Error \\| Details" in report
[docs] def test_report_no_source_url_plain_text(): """Without source_url, filenames and line numbers are plain text.""" broken = [ LinkcheckResult( filename="architectures.md", lineno=4, status="broken", uri="https://example.com/missing", info="404 Not Found", ), ] report = generate_markdown_report(broken) # File header should be plain text. assert "## `architectures.md`" in report assert "## [`architectures.md`](" not in report # Line number should be plain text, not a link. assert " 4 " in report assert "[4](" not in report
# --------------------------------------------------------------------------- # Sanitization of external tool output # --------------------------------------------------------------------------- ZWS = "\u200b"
[docs] @pytest.mark.parametrize( "info", ( pytest.param("403 Forbidden @dependabot", id="mention_in_info"), pytest.param( "Redirected to https://github.com/org/repo/issues/42", id="github_url_in_info", ), pytest.param("See #123 for details", id="issue_ref_in_info"), ), ) def test_report_leaves_external_content_unsanitized(info): """The report renderer passes tool output through; the caller sanitizes it. Splitting the two is what lets the same report be written to a file, where the mention would be inert, or posted to an issue, where it would not. `test_lychee_output_sanitized` covers the caller's half. """ broken = [ LinkcheckResult( filename="index.rst", lineno=1, status="broken", uri="https://example.com", info=info, ), ] report = generate_markdown_report(broken) # Only the table-breaking pipe is escaped; nothing else is touched. assert info.replace("|", "\\|") in report
[docs] def test_lychee_output_sanitized(tmp_path): """Lychee markdown output is sanitized before embedding in issue body.""" raw = "Broken: @admin mentioned in https://github.com/org/repo #99" sanitized = sanitize_markdown_mentions(raw) assert f"@{ZWS}admin" in sanitized assert "redirect.github.com/" in sanitized assert f"#{ZWS}99" in sanitized
# --------------------------------------------------------------------------- # Verdicts and the issue lifecycle # ---------------------------------------------------------------------------
[docs] @pytest.mark.parametrize( ("exit_code", "has_report", "verdict", "needle"), ( pytest.param(0, False, LinkVerdict.CLEAN, NO_BROKEN_LINKS, id="clean"), pytest.param(2, True, LinkVerdict.BROKEN, "Broken: mango.example", id="broken"), pytest.param( 2, False, LinkVerdict.UNKNOWN, "wrote no report", id="broken-no-report" ), pytest.param(1, True, LinkVerdict.UNKNOWN, "did not complete", id="failure"), pytest.param(3, False, LinkVerdict.UNKNOWN, "exit code 3", id="config-error"), ), ) def test_lychee_section_reads_the_exit_code(exit_code, has_report, verdict, needle): """Only exit 2 with a report reports broken links; 1 and 3 report nothing.""" report = "Broken: mango.example" if has_report else None section, found = lychee_section(LycheeRun(exit_code, report)) assert found is verdict assert section.startswith("## Lychee\n\n") assert needle in section
[docs] @pytest.mark.parametrize( ("status", "verdict"), (("working", LinkVerdict.CLEAN), ("broken", LinkVerdict.BROKEN)), ) def test_sphinx_section_reads_the_statuses(tmp_path, status, verdict): """Any broken or timed-out link makes the Sphinx verdict broken.""" output = tmp_path / "output.json" entry = { "filename": "index.rst", "lineno": 3, "status": status, "uri": "https://example.com/kiwi", } output.write_text(json.dumps(entry) + "\n", encoding="UTF-8") section, found = sphinx_section(output) assert found is verdict assert section.startswith("## Sphinx linkcheck\n\n")
def _manage(tmp_path, monkeypatch, exit_code, sphinx_status=None): """Run the lifecycle with lychee's *exit_code*, capturing the issue call.""" monkeypatch.chdir(tmp_path) output = None if sphinx_status: output = tmp_path / "output.json" entry = { "filename": "index.rst", "lineno": 3, "status": sphinx_status, "uri": "https://example.com/kiwi", } output.write_text(json.dumps(entry) + "\n", encoding="UTF-8") with patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle: verdict = manage_combined_broken_links_issue( repo_name="orchard", lychee=LycheeRun(exit_code, "Broken: mango.example"), sphinx_output_json=output, ) return verdict, lifecycle
[docs] @pytest.mark.parametrize("exit_code", (1, 3)) def test_a_failed_lychee_run_leaves_the_issue_alone(tmp_path, monkeypatch, exit_code): """A run that did not complete never closes an open broken links issue. It says nothing about the links, so claiming "No more issues." would close a report of links that may still be broken. """ verdict, lifecycle = _manage(tmp_path, monkeypatch, exit_code) assert verdict is LinkVerdict.UNKNOWN lifecycle.assert_not_called()
[docs] @pytest.mark.parametrize( ("exit_code", "has_issues"), (pytest.param(2, True, id="broken"), pytest.param(0, False, id="clean")), ) def test_a_completed_run_files_or_closes_the_issue( tmp_path, monkeypatch, exit_code, has_issues ): """A completed run decides the issue, either way.""" verdict, lifecycle = _manage(tmp_path, monkeypatch, exit_code) assert verdict is (LinkVerdict.BROKEN if has_issues else LinkVerdict.CLEAN) assert lifecycle.call_args.kwargs["has_issues"] is has_issues assert lifecycle.call_args.kwargs["labels"] == ["📚 documentation"]
[docs] def test_a_ci_run_detects_every_default(tmp_path, monkeypatch): """In CI, the name, the linkcheck output and its source URL are all detected.""" sha = "a1b2c3d4" * 5 monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_REPOSITORY", "orchard/awesome-papaya") monkeypatch.setenv("GITHUB_SHA", sha) output = tmp_path / "docs" / "_linkcheck" / "output.json" output.parent.mkdir(parents=True) entry = { "filename": "index.rst", "lineno": 3, "status": "broken", "uri": "https://example.com/kiwi", } output.write_text(json.dumps(entry) + "\n", encoding="UTF-8") with patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle: verdict = manage_combined_broken_links_issue() assert verdict is LinkVerdict.BROKEN kwargs = lifecycle.call_args.kwargs assert kwargs["labels"] == [FIX_LINK_LABEL] assert f"orchard/awesome-papaya/blob/{sha}/docs/index.rst" in kwargs["body"] assert "https://example.com/kiwi" in kwargs["body"]
[docs] def test_no_repository_name_to_detect_is_an_error(tmp_path, monkeypatch): """With neither `$GITHUB_REPOSITORY` nor a clone, no name selects the label.""" monkeypatch.chdir(tmp_path) monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) with ( patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle, pytest.raises(ValueError, match="No repository name specified"), ): manage_combined_broken_links_issue() lifecycle.assert_not_called()
[docs] def test_no_checker_leaves_the_issue_alone(tmp_path, monkeypatch, caplog): """With no checker run, nothing is known about the links: the issue stays. No crawl and no Sphinx linkcheck output said a word about the links, so closing an open issue would announce fixes that nobody checked. """ monkeypatch.chdir(tmp_path) with ( patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle, caplog.at_level("WARNING"), ): verdict = manage_combined_broken_links_issue(repo_name="orchard") assert verdict is None lifecycle.assert_not_called() assert "No link checker ran" in caplog.text
[docs] def test_cli_with_no_checker_exits_clean(tmp_path, monkeypatch): """`--no-lychee` with no linkcheck output leaves the issue, and exits 0. The job is advisory: a run that checked nothing must not turn it red. """ monkeypatch.chdir(tmp_path) monkeypatch.setenv("GH_TOKEN", "fake-token") with patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle: result = CliRunner().invoke( repomatic, ["broken-links", "--no-lychee", "--repo-name", "orchard"] ) assert result.exit_code == 0, result.output lifecycle.assert_not_called()
[docs] def test_cli_without_a_repository_name_is_a_usage_error(tmp_path, monkeypatch): """No `--repo-name` and nothing to detect one from fails before any check.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("GH_TOKEN", "fake-token") with ( patch( "repomatic.broken_links.Metadata", return_value=SimpleNamespace(repo_name=None), ), patch("repomatic.broken_links.manage_issue_lifecycle") as lifecycle, ): result = CliRunner().invoke(repomatic, ["broken-links", "--no-lychee"]) assert result.exit_code == 2 assert ( "No repository name specified. Set --repo-name or $GITHUB_REPOSITORY." in result.output ) lifecycle.assert_not_called()
# --------------------------------------------------------------------------- # The lychee crawl # ---------------------------------------------------------------------------
[docs] def test_run_lychee_skips_a_repository_with_no_documentation(tmp_path, monkeypatch): """With no documentation file, lychee has nothing to check and never runs.""" monkeypatch.chdir(tmp_path) with patch("repomatic.broken_links.run_tool") as run_tool: assert run_lychee() is None run_tool.assert_not_called()
[docs] def test_run_lychee_reads_back_the_report_it_asked_for(tmp_path, monkeypatch): """The crawl is the bare `run lychee` invocation, plus the report flags. The report reaches the issue through the run itself, with no path agreed on with a workflow step. """ monkeypatch.chdir(tmp_path) Path("readme.md").write_text("# Orchard\n", encoding="UTF-8") calls = [] def crawl(name, extra_args): calls.append((name, extra_args)) output = extra_args[extra_args.index("--output") + 1] Path(output).write_text("Broken: kiwi.example", encoding="UTF-8") return 2 with patch("repomatic.broken_links.run_tool", side_effect=crawl): run = run_lychee() assert run == LycheeRun(2, "Broken: kiwi.example") ((name, args),) = calls assert name == "lychee" assert args[: len(LYCHEE_REPORT_FORMAT)] == list(LYCHEE_REPORT_FORMAT) assert args[-1] == "readme.md" for flag in TOOL_REGISTRY["lychee"].default_args: assert flag in args
[docs] def test_run_lychee_without_a_report_says_so(tmp_path, monkeypatch): """A crawl that wrote no report carries none, whatever its exit code.""" monkeypatch.chdir(tmp_path) Path("readme.md").write_text("# Orchard\n", encoding="UTF-8") with patch("repomatic.broken_links.run_tool", return_value=1): assert run_lychee() == LycheeRun(1)
[docs] def test_run_lychee_that_cannot_install_did_not_complete(tmp_path, monkeypatch): """A lychee that cannot be installed checked nothing: the issue stays put.""" monkeypatch.chdir(tmp_path) Path("readme.md").write_text("# Orchard\n", encoding="UTF-8") refused = ToolInstallError("no binary for this platform") with patch("repomatic.broken_links.run_tool", side_effect=refused): run = run_lychee() assert run == LycheeRun(LYCHEE_INSTALL_FAILURE_EXIT) _section, verdict = lychee_section(run) assert verdict is LinkVerdict.UNKNOWN
[docs] @pytest.mark.parametrize( ("flag", "crawls"), ( pytest.param((), True, id="default"), pytest.param(("--no-lychee",), False, id="no-lychee"), ), ) def test_cli_crawls_unless_told_not_to(monkeypatch, flag, crawls): """`broken-links` runs lychee itself, and `--no-lychee` leaves it out.""" monkeypatch.setenv("GH_TOKEN", "fake-token") crawl = LycheeRun(0) with ( patch("repomatic.cli.github.run_lychee", return_value=crawl) as run, patch("repomatic.cli.github.manage_combined_broken_links_issue") as manage, ): result = CliRunner().invoke( repomatic, ["broken-links", "--repo-name", "orchard", *flag] ) assert result.exit_code == 0, result.output assert run.called is crawls assert manage.call_args.kwargs["lychee"] == (crawl if crawls else None)