test: add regression guards for the bug classes fixed in this PR

Three lightweight static checks, all enrolled in CI's unit-test
allowlist along with the new web-cache test:

- test_template_targets.py: every literal render_template() target must
  exist (would have caught the weather/stocks partial 500s at commit
  time).
- test_widget_scripts.py: every widget JS file must be script-included
  in base.html or explicitly allowlisted with a reason (would have
  caught the unloaded time-picker/file-upload-single/plugin-file-manager
  widgets), and allowlisted files must NOT be included (prevents the
  example widget from shadowing the real color-picker).
- test_doc_links.py: relative markdown links in active docs must
  resolve (docs/archive/ exempt).

Each guard was verified to fail against the pre-PR tree and pass now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
This commit is contained in:
Claude
2026-08-06 00:11:48 +00:00
parent e1fa597b29
commit 6edd6f9beb
4 changed files with 126 additions and 1 deletions
+5 -1
View File
@@ -80,4 +80,8 @@ jobs:
test/test_version_consistency.py \ test/test_version_consistency.py \
test/test_plugin_compatibility_gate.py \ test/test_plugin_compatibility_gate.py \
test/test_install_preserves_existing.py \ test/test_install_preserves_existing.py \
test/test_core_owned_config_keys.py test/test_core_owned_config_keys.py \
test/test_template_targets.py \
test/test_widget_scripts.py \
test/test_doc_links.py \
test/web_interface/test_cache.py
+40
View File
@@ -0,0 +1,40 @@
"""Guard: relative markdown links in active docs must resolve.
Scans repo-root *.md and docs/ (excluding docs/archive/, which is allowed
to rot). External URLs, mailto links, and pure anchors are skipped, as are
links inside fenced code blocks.
"""
import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)')
FENCE_RE = re.compile(r'^(```|~~~)')
def _md_files():
yield from PROJECT_ROOT.glob('*.md')
for path in PROJECT_ROOT.glob('docs/**/*.md'):
if 'archive' not in path.parts:
yield path
def test_relative_markdown_links_resolve():
broken = []
for md in _md_files():
in_fence = False
for lineno, line in enumerate(md.read_text(encoding='utf-8').splitlines(), 1):
if FENCE_RE.match(line.strip()):
in_fence = not in_fence
continue
if in_fence:
continue
for target in LINK_RE.findall(line):
if target.startswith(('http://', 'https://', 'mailto:', '#')):
continue
resolved = (md.parent / target.split('#')[0]).resolve()
if not resolved.exists():
broken.append(
f'{md.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
)
assert not broken, 'Broken relative markdown links:\n' + '\n'.join(broken)
+33
View File
@@ -0,0 +1,33 @@
"""Guard: every literal render_template() target must exist on disk.
Catches routes that reference templates deleted in a refactor (a real bug
class: the weather/stocks partials 500'd for months because their
templates were removed when those displays became plugins).
"""
import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATE_ROOT = PROJECT_ROOT / 'web_interface' / 'templates'
RENDER_RE = re.compile(r"""render_template\(\s*['"]([^'"]+)['"]""")
def _python_sources():
yield PROJECT_ROOT / 'web_interface' / 'app.py'
yield from (PROJECT_ROOT / 'web_interface' / 'blueprints').glob('*.py')
def test_all_literal_render_template_targets_exist():
missing = []
for source in _python_sources():
text = source.read_text(encoding='utf-8')
for lineno, line in enumerate(text.splitlines(), 1):
for target in RENDER_RE.findall(line):
if not (TEMPLATE_ROOT / target).is_file():
missing.append(
f'{source.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
)
assert not missing, (
'render_template() references templates that do not exist under '
f'web_interface/templates/:\n' + '\n'.join(missing)
)
+48
View File
@@ -0,0 +1,48 @@
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
Widget files register themselves with LEDMatrixWidgets at load time; a file
that exists but is never <script>-included silently breaks any plugin whose
config schema declares that widget (the field renders as an empty container
that polls the registry forever). base.html's widget list is maintained by
hand, so this test keeps it honest.
"""
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
# Files that must NOT be script-included, with the reason.
ALLOWLIST = {
# Documentation example (docs/widget-guide.md); registers the name
# 'color-picker' and would shadow the real color-picker.js if loaded.
'example-color-picker.js',
}
def test_every_widget_script_is_included_in_base_html():
base_html = BASE_HTML.read_text(encoding='utf-8')
missing = []
for js_file in sorted(WIDGETS_DIR.glob('*.js')):
if js_file.name in ALLOWLIST:
continue
if f'v3/js/widgets/{js_file.name}' not in base_html:
missing.append(js_file.name)
assert not missing, (
'Widget files exist but are never <script>-included in base.html '
'(plugins declaring these widgets get blank config fields): '
+ ', '.join(missing)
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
'with a reason.'
)
def test_allowlisted_widgets_are_not_included():
base_html = BASE_HTML.read_text(encoding='utf-8')
wrongly_included = [
name for name in ALLOWLIST if f'v3/js/widgets/{name}' in base_html
]
assert not wrongly_included, (
'Allowlisted (must-not-load) widget files are script-included in '
'base.html: ' + ', '.join(wrongly_included)
)