diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..ae9823d --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,272 @@ +"""README quality and consistency tests. + +Testing library/framework: pytest +- These tests use only Python stdlib to avoid introducing new dependencies. +- They validate structure, key sections, badge/link syntax, and critical snippets + from the README to catch regressions in documentation that users depend on. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from urllib.parse import urlparse + +import sys + +README_CANDIDATES = [ + Path("README.md"), + Path("Readme.md"), + Path("README.rst"), +] +README_PATH = next((p for p in README_CANDIDATES if p.exists()), None) + + +def _require_readme() -> str: + assert README_PATH is not None, ( + f"README file not found. Looked for: {', '.join(map(str, README_CANDIDATES))}" + ) + return README_PATH.read_text(encoding="utf-8", errors="replace") + + +def test_readme_has_expected_title_and_intro(): + readme = _require_readme() + assert "Commit-Check GitHub Action" in readme + assert "A GitHub Action for checking commit message formatting" in readme + + +def test_table_of_contents_contains_expected_sections_ordered(): + readme = _require_readme() + # Basic presence checks + for section in [ + "Usage", + "Optional Inputs", + "GitHub Action Job Summary", + "GitHub Pull Request Comments", + "Badging Your Repository", + "Versioning", + ]: + assert f"[{section}]" in readme or f"## {section}" in readme, f"Missing section: {section}" + + # Check that TOC links match headers (anchor-style) + toc_block_match = re.search(r"## Table of Contents\s+([\s\S]+?)\n## ", readme) + assert toc_block_match, "Table of Contents block not found" + toc_block = toc_block_match.group(1) + anchors = re.findall(r"\*\s+\[(.+?)\]\(#([a-z0-9\-]+)\)", toc_block, flags=re.I) + assert anchors, "No TOC anchors found" + # Ensure each anchor has a corresponding header + for label, anchor in anchors: + header_pattern = rf"^##\s+{re.escape(label)}\s*$" + assert re.search(header_pattern, readme, flags=re.M), ( + f"Header for TOC entry '{label}' not found" + ) + + +def test_badges_and_links_are_well_formed_urls(): + readme = _require_readme() + # Collect all markdown image and link URLs + urls = [] + urls += re.findall(r"\!\[[^\]]*\]\((https?://[^)]+)\)", readme) # images + urls += re.findall(r"\[[^\]]*\]\((https?://[^)]+)\)", readme) # links + + assert urls, "No URLs found in README; expected badges/links to be present" + + for u in urls: + parsed = urlparse(u) + assert parsed.scheme in ("http", "https"), f"Unexpected URL scheme in {u}" + assert parsed.netloc, f"URL missing host: {u}" + # Basic sanity: disallow spaces + assert " " not in u, f"URL contains spaces: {u}" + + # Spot-check expected badge providers/domains + assert any("img.shields.io" in u for u in urls), "Shields.io badges should be present" + assert any("github.com/commit-check/commit-check-action" in u for u in urls), ( + "Repository links should be present" + ) + + +def test_usage_yaml_snippet_contains_expected_github_actions_fields(): + readme = _require_readme() + # Extract the fenced yaml code block under Usage + usage_match = re.search(r"## Usage[\s\S]+?```yaml([\s\S]+?)```", readme, flags=re.I) + assert usage_match, "Usage YAML block not found" + yaml_text = usage_match.group(1) + + # Validate presence of critical keys/values by regex (no external YAML dependency) + expected_lines = [ + r"^name:\s*Commit Check\s*$", + r"^on:\s*$", + r"^\s+push:\s*$", + r"^\s+pull_request:\s*$", + r"^\s+branches:\s*'main'\s*$", + r"^jobs:\s*$", + r"^\s+commit-check:\s*$", + r"^\s+runs-on:\s*ubuntu-latest\s*$", + r"^\s+permissions:\s*#? ?use permissions because use of pr-comments\s*$", + r"^\s+contents:\s*read\s*$", + r"^\s+pull-requests:\s*write\s*$", + r"^\s+steps:\s*$", + r"^\s+- uses:\s*actions/checkout@v5\s*$", + r"^\s+ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.sha\s*\}\}\s*# checkout PR HEAD commit\s*$", + r"^\s+fetch-depth:\s*0\s*# required for merge-base check\s*$", + r"^\s+- uses:\s*commit-check/commit-check-action@v1\s*$", + r"^\s+env:\s*$", + r"^\s+GITHUB_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}\s*# use GITHUB_TOKEN because use of pr-comments\s*$", + r"^\s+with:\s*$", + r"^\s+message:\s*true\s*$", + r"^\s+branch:\s*true\s*$", + r"^\s+author-name:\s*true\s*$", + r"^\s+author-email:\s*true\s*$", + r"^\s+commit-signoff:\s*true\s*$", + r"^\s+merge-base:\s*false\s*$", + r"^\s+imperative:\s*false\s*$", + r"^\s+job-summary:\s*true\s*$", + r"^\s+pr-comments:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'\s*\}\}\s*$", + ] + for pattern in expected_lines: + assert re.search(pattern, yaml_text, flags=re.M), ( + f"Missing or malformed line in Usage YAML matching: {pattern}" + ) + + +def test_optional_inputs_section_lists_all_expected_inputs_with_defaults(): + readme = _require_readme() + # Build a map of input -> default as shown + items = { + "message": "true", + "branch": "true", + "author-name": "true", + "author-email": "true", + "commit-signoff": "true", + "merge-base": "false", + "imperative": "false", + "dry-run": "false", + "job-summary": "true", + "pr-comments": "false", + } + + # Ensure each input has a dedicated subsection header and default line + for key, default in items.items(): + header_pat = rf"^###\s*`{re.escape(key)}`\s*$" + assert re.search(header_pat, readme, flags=re.M), f"Missing Optional Inputs header for `{key}`" + default_pat = rf"^-+\s*Default:\s*`{re.escape(default)}`" + # Search within a bounded region (from header to either next ### or end) + section_match = re.search(header_pat + r"([\s\S]+?)(^###\s*`|\Z)", readme, flags=re.M) + assert section_match, f"Section body for `{key}` not found" + section_body = section_match.group(1) + assert re.search(default_pat, section_body, flags=re.M), ( + f"Default for `{key}` should be `{default}`" + ) + + +def test_merge_base_important_note_present_and_mentions_fetch_depth_zero(): + readme = _require_readme() + note_match = re.search( + r"###\s*`merge-base`[\s\S]+?>\s*\[\!IMPORTANT\][\s\S]+?fetch-depth:\s*0", readme, flags=re.I + ) + assert note_match, "IMPORTANT note for `merge-base` with fetch-depth: 0 is missing" + + +def test_pr_comments_important_note_mentions_github_token_and_issue_77(): + readme = _require_readme() + assert "### `pr-comments`" in readme + assert re.search( + r">\s*\[\!IMPORTANT\][\s\S]+GITHUB_TOKEN[\s\S]+#77", readme + ), "IMPORTANT note for `pr-comments` should mention GITHUB_TOKEN and issue #77" + + +def test_used_by_section_contains_expected_orgs_and_structure(): + readme = _require_readme() + # Simple checks for known org names and ... + expected_orgs = [ + ("Apache", "https://github.com/apache"), + ("Texas Instruments", "https://github.com/TexasInstruments"), + ("OpenCADC", "https://github.com/opencadc"), + ("Extrawest", "https://github.com/extrawest"), + ("Mila", "https://github.com/mila-iqia"), + ("Chainlift", "https://github.com/Chainlift"), + ] + for alt, href in expected_orgs: + assert alt in readme, f"Expected org '{alt}' not mentioned" + assert href in readme, f"Expected org link '{href}' not present" + # Check that avatars come from GitHub's avatars CDN + assert re.search( + r'src="https://avatars\.githubusercontent\.com/u/\d+\?s=200&v=4"', readme + ), "Org avatar images should use githubusercontent avatars" + + +def test_badging_section_contains_markdown_and_rst_snippets(): + readme = _require_readme() + # Markdown fenced snippet + assert re.search( + r"\[\!\[Commit Check\]\(https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml/badge\.svg\)\]" + r"\(https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml\)", + readme, + ), "Markdown badge snippet missing or malformed" + + # reStructuredText snippet + assert re.search( + r"\.\. image:: https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml/badge\.svg\s+" + r":target: https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml\s+" + r":alt: Commit Check", + readme, + ), "reStructuredText badge snippet missing or malformed" + + +def test_versioning_and_feedback_sections_present_with_expected_links(): + readme = _require_readme() + assert "Versioning follows" in readme and "Semantic Versioning" in readme + # Feedback/issues link + assert re.search( + r"\[issues\]\(https://github\.com/commit-check/commit-check/issues\)", readme + ), "Issues link in feedback section is missing" + + +def test_all_markdown_links_and_images_have_alt_text_or_label(): + readme = _require_readme() + # Images must have alt text inside \![...] + for m in re.finditer(r"\!\[(?P[^\]]*)\]\((?Phttps?://[^)]+)\)", readme): + alt = (m.group("alt") or "").strip() + assert alt != "", f"Image missing alt text for URL {m.group('url')}" + + # Links should have non-empty labels + for m in re.finditer(r"\[(?P