1+ """
2+ README quality and consistency tests.
3+
4+ Testing library/framework: pytest
5+ - These tests use only Python stdlib to avoid introducing new dependencies.
6+ - They validate structure, key sections, badge/link syntax, and critical snippets
7+ from the README to catch regressions in documentation that users depend on.
8+ """
9+ from __future__ import annotations
10+
11+ import re
12+ from pathlib import Path
13+ from urllib .parse import urlparse
14+
15+ import sys
16+
17+ README_CANDIDATES = [
18+ Path ("README.md" ),
19+ Path ("Readme.md" ),
20+ Path ("README.rst" ),
21+ ]
22+ README_PATH = next ((p for p in README_CANDIDATES if p .exists ()), None )
23+
24+
25+ def _require_readme () -> str :
26+ assert README_PATH is not None , f"README file not found. Looked for: { ', ' .join (map (str , README_CANDIDATES ))} "
27+ return README_PATH .read_text (encoding = "utf-8" , errors = "replace" )
28+
29+
30+ def test_readme_has_expected_title_and_intro ():
31+ readme = _require_readme ()
32+ assert "Commit-Check GitHub Action" in readme
33+ assert "A GitHub Action for checking commit message formatting" in readme
34+
35+
36+ def test_table_of_contents_contains_expected_sections_ordered ():
37+ readme = _require_readme ()
38+ # Basic presence checks
39+ for section in [
40+ "Usage" ,
41+ "Optional Inputs" ,
42+ "GitHub Action Job Summary" ,
43+ "GitHub Pull Request Comments" ,
44+ "Badging Your Repository" ,
45+ "Versioning" ,
46+ ]:
47+ assert f"[{ section } ]" in readme or f"## { section } " in readme , f"Missing section: { section } "
48+
49+ # Check that TOC links match headers (anchor-style)
50+ toc_block_match = re .search (r"## Table of Contents\s+([\s\S]+?)\n## " , readme )
51+ assert toc_block_match , "Table of Contents block not found"
52+ toc_block = toc_block_match .group (1 )
53+ anchors = re .findall (r"\*\s+\[(.+?)\]\(#([a-z0-9\-]+)\)" , toc_block , flags = re .I )
54+ assert anchors , "No TOC anchors found"
55+ # Ensure each anchor has a corresponding header
56+ for label , anchor in anchors :
57+ header_pattern = rf"^##\s+{ re .escape (label )} \s*$"
58+ assert re .search (header_pattern , readme , flags = re .M ), f"Header for TOC entry '{ label } ' not found"
59+
60+
61+ def test_badges_and_links_are_well_formed_urls ():
62+ readme = _require_readme ()
63+ # Collect all markdown image and link URLs
64+ urls = []
65+ urls += re .findall (r"\!\[[^\]]*\]\((https?://[^)]+)\)" , readme ) # images
66+ urls += re .findall (r"\[[^\]]*\]\((https?://[^)]+)\)" , readme ) # links
67+
68+ assert urls , "No URLs found in README; expected badges/links to be present"
69+
70+ for u in urls :
71+ parsed = urlparse (u )
72+ assert parsed .scheme in ("http" , "https" ), f"Unexpected URL scheme in { u } "
73+ assert parsed .netloc , f"URL missing host: { u } "
74+ # Basic sanity: disallow spaces
75+ assert " " not in u , f"URL contains spaces: { u } "
76+
77+ # Spot-check expected badge providers/domains
78+ assert any ("img.shields.io" in u for u in urls ), "Shields.io badges should be present"
79+ assert any ("github.com/commit-check/commit-check-action" in u for u in urls ), "Repository links should be present"
80+
81+
82+ def test_usage_yaml_snippet_contains_expected_github_actions_fields ():
83+ readme = _require_readme ()
84+ # Extract the fenced yaml code block under Usage
85+ usage_match = re .search (r"## Usage[\s\S]+?```yaml([\s\S]+?)```" , readme , flags = re .I )
86+ assert usage_match , "Usage YAML block not found"
87+ yaml_text = usage_match .group (1 )
88+
89+ # Validate presence of critical keys/values by regex (no external YAML dependency)
90+ expected_lines = [
91+ r"^name:\s*Commit Check\s*$" ,
92+ r"^on:\s*$" ,
93+ r"^\s+push:\s*$" ,
94+ r"^\s+pull_request:\s*$" ,
95+ r"^\s+branches:\s*'main'\s*$" ,
96+ r"^jobs:\s*$" ,
97+ r"^\s+commit-check:\s*$" ,
98+ r"^\s+runs-on:\s*ubuntu-latest\s*$" ,
99+ r"^\s+permissions:\s*#? ?use permissions because use of pr-comments\s*$" ,
100+ r"^\s+contents:\s*read\s*$" ,
101+ r"^\s+pull-requests:\s*write\s*$" ,
102+ r"^\s+steps:\s*$" ,
103+ r"^\s+- uses:\s*actions/checkout@v5\s*$" ,
104+ r"^\s+ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.sha\s*\}\}\s*# checkout PR HEAD commit\s*$" ,
105+ r"^\s+fetch-depth:\s*0\s*# required for merge-base check\s*$" ,
106+ r"^\s+- uses:\s*commit-check/commit-check-action@v1\s*$" ,
107+ r"^\s+env:\s*$" ,
108+ r"^\s+GITHUB_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}\s*# use GITHUB_TOKEN because use of pr-comments\s*$" ,
109+ r"^\s+with:\s*$" ,
110+ r"^\s+message:\s*true\s*$" ,
111+ r"^\s+branch:\s*true\s*$" ,
112+ r"^\s+author-name:\s*true\s*$" ,
113+ r"^\s+author-email:\s*true\s*$" ,
114+ r"^\s+commit-signoff:\s*true\s*$" ,
115+ r"^\s+merge-base:\s*false\s*$" ,
116+ r"^\s+imperative:\s*false\s*$" ,
117+ r"^\s+job-summary:\s*true\s*$" ,
118+ r"^\s+pr-comments:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'\s*\}\}\s*$" ,
119+ ]
120+ for pattern in expected_lines :
121+ assert re .search (pattern , yaml_text , flags = re .M ), f"Missing or malformed line in Usage YAML matching: { pattern } "
122+
123+
124+ def test_optional_inputs_section_lists_all_expected_inputs_with_defaults ():
125+ readme = _require_readme ()
126+ # Build a map of input -> default as shown
127+ items = {
128+ "message" : "true" ,
129+ "branch" : "true" ,
130+ "author-name" : "true" ,
131+ "author-email" : "true" ,
132+ "commit-signoff" : "true" ,
133+ "merge-base" : "false" ,
134+ "imperative" : "false" ,
135+ "dry-run" : "false" ,
136+ "job-summary" : "true" ,
137+ "pr-comments" : "false" ,
138+ }
139+
140+ # Ensure each input has a dedicated subsection header and default line
141+ for key , default in items .items ():
142+ header_pat = rf"^###\s*`{ re .escape (key )} `\s*$"
143+ assert re .search (header_pat , readme , flags = re .M ), f"Missing Optional Inputs header for `{ key } `"
144+ default_pat = rf"^-+\s*Default:\s*`{ re .escape (default )} `"
145+ # Search within a bounded region (from header to either next ### or end)
146+ section_match = re .search (header_pat + r"([\s\S]+?)(^###\s*`|\Z)" , readme , flags = re .M )
147+ assert section_match , f"Section body for `{ key } ` not found"
148+ section_body = section_match .group (1 )
149+ assert re .search (default_pat , section_body , flags = re .M ), f"Default for `{ key } ` should be `{ default } `"
150+
151+
152+ def test_merge_base_important_note_present_and_mentions_fetch_depth_zero ():
153+ readme = _require_readme ()
154+ note_match = re .search (r"###\s*`merge-base`[\s\S]+?>\s*\[\!IMPORTANT\][\s\S]+?fetch-depth:\s*0" , readme , flags = re .I )
155+ assert note_match , "IMPORTANT note for `merge-base` with fetch-depth: 0 is missing"
156+
157+
158+ def test_pr_comments_important_note_mentions_github_token_and_issue_77 ():
159+ readme = _require_readme ()
160+ assert "### `pr-comments`" in readme
161+ assert re .search (r">\s*\[\!IMPORTANT\][\s\S]+GITHUB_TOKEN[\s\S]+#77" , readme ), \
162+ "IMPORTANT note for `pr-comments` should mention GITHUB_TOKEN and issue #77"
163+
164+
165+ def test_used_by_section_contains_expected_orgs_and_structure ():
166+ readme = _require_readme ()
167+ # Simple checks for known org names and <img ... alt="...">
168+ expected_orgs = [
169+ ("Apache" , "https://github.com/apache" ),
170+ ("Texas Instruments" , "https://github.com/TexasInstruments" ),
171+ ("OpenCADC" , "https://github.com/opencadc" ),
172+ ("Extrawest" , "https://github.com/extrawest" ),
173+ ("Mila" , "https://github.com/mila-iqia" ),
174+ ("Chainlift" , "https://github.com/Chainlift" ),
175+ ]
176+ for alt , href in expected_orgs :
177+ assert alt in readme , f"Expected org '{ alt } ' not mentioned"
178+ assert href in readme , f"Expected org link '{ href } ' not present"
179+ # Check that avatars come from GitHub's avatars CDN
180+ assert re .search (r'src="https://avatars\.githubusercontent\.com/u/\d+\?s=200&v=4"' , readme ), \
181+ "Org avatar images should use githubusercontent avatars"
182+
183+
184+ def test_badging_section_contains_markdown_and_rst_snippets ():
185+ readme = _require_readme ()
186+ # Markdown fenced snippet
187+ assert re .search (
188+ r"\[\!\[Commit Check\]\(https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml/badge\.svg\)\]"
189+ r"\(https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml\)" ,
190+ readme ,
191+ ), "Markdown badge snippet missing or malformed"
192+
193+ # reStructuredText snippet
194+ assert re .search (
195+ r"\.\. image:: https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml/badge\.svg\s+"
196+ r":target: https://github\.com/commit-check/commit-check-action/actions/workflows/commit-check\.yml\s+"
197+ r":alt: Commit Check" ,
198+ readme ,
199+ ), "reStructuredText badge snippet missing or malformed"
200+
201+
202+ def test_versioning_and_feedback_sections_present_with_expected_links ():
203+ readme = _require_readme ()
204+ assert "Versioning follows" in readme and "Semantic Versioning" in readme
205+ # Feedback/issues link
206+ assert re .search (r"\[issues\]\(https://github\.com/commit-check/commit-check/issues\)" , readme ), \
207+ "Issues link in feedback section is missing"
208+
209+
210+ def test_all_markdown_links_and_images_have_alt_text_or_label ():
211+ readme = _require_readme ()
212+ # Images must have alt text inside \![...]
213+ for m in re .finditer (r"\!\[(?P<alt>[^\]]*)\]\((?P<url>https?://[^)]+)\)" , readme ):
214+ alt = (m .group ("alt" ) or "" ).strip ()
215+ assert alt != "" , f"Image missing alt text for URL { m .group ('url' )} "
216+
217+ # Links should have non-empty labels
218+ for m in re .finditer (r"\[(?P<label>[^\]]+)\]\((?P<url>https?://[^)]+)\)" , readme ):
219+ label = (m .group ("label" ) or "" ).strip ()
220+ assert label != "" , f"Link missing label for URL { m .group ('url' )} "
221+
222+
223+ def test_no_http_links_only_https ():
224+ readme = _require_readme ()
225+ http_links = re .findall (r"\((http://[^)]+)\)" , readme )
226+ assert not http_links , f"Insecure http links found: { http_links } "
227+
228+
229+ def test_job_summary_and_pr_comments_screenshots_referenced ():
230+ readme = _require_readme ()
231+ assert "screenshot/success-job-summary.png" in readme
232+ assert "screenshot/failure-job-summary.png" in readme
233+ assert "screenshot/success-pr-comments.png" in readme
234+ assert "screenshot/failure-pr-comments.png" in readme
235+
236+
237+ # Edge cases / failure scenarios
238+
239+
240+ def test_readme_is_not_empty_and_has_min_length ():
241+ readme = _require_readme ()
242+ assert len (readme .strip ()) > 400 , "README seems too short; expected a substantive document"
243+
244+
245+ def test_usage_block_contains_both_checkout_and_action_steps_once_each ():
246+ readme = _require_readme ()
247+ usage_match = re .search (r"## Usage[\s\S]+?```yaml([\s\S]+?)```" , readme , flags = re .I )
248+ assert usage_match , "Usage YAML block not found"
249+ yaml_text = usage_match .group (1 )
250+ assert yaml_text .count ("actions/checkout@v5" ) == 1 , "Expected one checkout step"
251+ assert yaml_text .count ("commit-check/commit-check-action@v1" ) == 1 , "Expected one commit-check-action step"
252+
253+
254+ def test_references_to_experimental_features_present ():
255+ readme = _require_readme ()
256+ assert re .search (r"`merge-base`.*experimental feature" , readme , flags = re .I | re .S )
257+ assert re .search (r"`pr-comments`.*experimental feature" , readme , flags = re .I | re .S )
0 commit comments