Add split integrity tests (61 tests)
This commit is contained in:
parent
a66c4d06e1
commit
77b069acf4
1 changed files with 304 additions and 0 deletions
304
tests/test_script_split_integrity.py
Normal file
304
tests/test_script_split_integrity.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Tests for the script.js → module split integrity.
|
||||
|
||||
Verifies that:
|
||||
- The monolithic script.js no longer exists
|
||||
- All expected split modules are present on disk
|
||||
- index.html loads all split modules via <script> tags
|
||||
- core.js loads first and init.js loads last (ordering contract)
|
||||
- No duplicate top-level function declarations across modules
|
||||
- Every onclick="fn(…)" in index.html has a matching function
|
||||
declaration in one of the split modules
|
||||
- No module references undefined globals at parse time via
|
||||
window.X = X assignments (the bug pattern we fixed)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_STATIC = _ROOT / "webui" / "static"
|
||||
_INDEX = _ROOT / "webui" / "index.html"
|
||||
|
||||
# The 17 modules that replaced script.js (order matters for first/last checks)
|
||||
SPLIT_MODULES = [
|
||||
"core.js",
|
||||
"media-player.js",
|
||||
"settings.js",
|
||||
"search.js",
|
||||
"sync-spotify.js",
|
||||
"downloads.js",
|
||||
"wishlist-tools.js",
|
||||
"sync-services.js",
|
||||
"artists.js",
|
||||
"api-monitor.js",
|
||||
"library.js",
|
||||
"beatport-ui.js",
|
||||
"discover.js",
|
||||
"enrichment.js",
|
||||
"stats-automations.js",
|
||||
"pages-extra.js",
|
||||
"init.js",
|
||||
]
|
||||
|
||||
# Other JS files that exist in static/ but are NOT part of the split
|
||||
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js"}
|
||||
|
||||
# Pre-existing duplicate helper functions that lived in the original monolith.
|
||||
# In a plain <script> context the last-loaded declaration wins. These are NOT
|
||||
# regressions from the split — they should be deduplicated in a follow-up.
|
||||
KNOWN_CROSS_FILE_DUPES = {
|
||||
"escapeHtml", # downloads.js, artists.js, discover.js
|
||||
"formatDuration", # sync-spotify.js, wishlist-tools.js, sync-services.js
|
||||
"matchedDownloadTrack", # downloads.js, wishlist-tools.js
|
||||
"matchedDownloadAlbum", # downloads.js, wishlist-tools.js
|
||||
"matchedDownloadAlbumTrack", # downloads.js, wishlist-tools.js
|
||||
"_esc", # library.js, stats-automations.js
|
||||
"_escAttr", # downloads.js, stats-automations.js
|
||||
"_formatDuration", # stats-automations.js, pages-extra.js
|
||||
"loadDashboardData", # search.js, wishlist-tools.js
|
||||
}
|
||||
|
||||
# Pre-existing same-file duplicates (two filter UIs reuse the same names).
|
||||
KNOWN_SAME_FILE_DUPES = {
|
||||
"applyFiltersAndSort",
|
||||
"calculateRelevanceScore",
|
||||
"handleFilterClick",
|
||||
"initializeFilters",
|
||||
"resetFilters",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FUNC_DECL_RE = re.compile(r"^(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE)
|
||||
_ONCLICK_RE = re.compile(r'onclick="([^"]*)"')
|
||||
_ONCLICK_FN_RE = re.compile(r"^([A-Za-z_$][A-Za-z0-9_$]*)\s*\(")
|
||||
_WINDOW_ASSIGN_RE = re.compile(
|
||||
r"^window\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*;",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_SCRIPT_SRC_RE = re.compile(r"filename='([^']+\.js)'")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _all_function_decls(js_text: str) -> list[str]:
|
||||
"""Return all top-level function declaration names in a JS file."""
|
||||
return _FUNC_DECL_RE.findall(js_text)
|
||||
|
||||
|
||||
def _script_load_order(html: str) -> list[str]:
|
||||
"""Return the ordered list of JS filenames loaded from index.html."""
|
||||
return _SCRIPT_SRC_RE.findall(html)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group A — File Existence
|
||||
# =========================================================================
|
||||
|
||||
class TestFileExistence:
|
||||
"""The old monolith is gone and all split modules are present."""
|
||||
|
||||
def test_monolith_removed(self):
|
||||
assert not (_STATIC / "script.js").exists(), "script.js should have been removed"
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_split_module_exists(self, module):
|
||||
path = _STATIC / module
|
||||
assert path.exists(), f"{module} missing from webui/static/"
|
||||
assert path.stat().st_size > 0, f"{module} is empty"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — index.html Script Loading
|
||||
# =========================================================================
|
||||
|
||||
class TestScriptLoading:
|
||||
"""index.html references every split module in the correct order."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_html(self):
|
||||
self.html = _read(_INDEX)
|
||||
self.loaded = _script_load_order(self.html)
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_module_loaded_in_html(self, module):
|
||||
assert module in self.loaded, f"{module} not loaded in index.html"
|
||||
|
||||
def test_core_loads_first(self):
|
||||
"""core.js must be the first split module loaded."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert split_in_html[0] == "core.js", (
|
||||
f"Expected core.js first, got {split_in_html[0]}"
|
||||
)
|
||||
|
||||
def test_init_loads_last(self):
|
||||
"""init.js must be the last split module loaded."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert split_in_html[-1] == "init.js", (
|
||||
f"Expected init.js last, got {split_in_html[-1]}"
|
||||
)
|
||||
|
||||
def test_no_duplicate_script_tags(self):
|
||||
"""Each module should only be loaded once."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert len(split_in_html) == len(set(split_in_html)), (
|
||||
"Duplicate script tags detected"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group C — No Duplicate Function Declarations
|
||||
# =========================================================================
|
||||
|
||||
class TestNoDuplicateFunctions:
|
||||
"""No two split modules should declare the same top-level function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan_all(self):
|
||||
self.func_map: dict[str, list[str]] = defaultdict(list)
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
for fn_name in _all_function_decls(text):
|
||||
self.func_map[fn_name].append(module)
|
||||
|
||||
def test_no_new_cross_file_duplicates(self):
|
||||
"""Catch NEW duplicate declarations; known pre-existing ones are allowed."""
|
||||
dupes = {
|
||||
fn: files
|
||||
for fn, files in self.func_map.items()
|
||||
if len(files) > 1
|
||||
and fn not in KNOWN_CROSS_FILE_DUPES
|
||||
and fn not in KNOWN_SAME_FILE_DUPES
|
||||
}
|
||||
assert not dupes, (
|
||||
"NEW duplicate function declarations across modules:\n"
|
||||
+ "\n".join(f" {fn}: {files}" for fn, files in sorted(dupes.items()))
|
||||
)
|
||||
|
||||
def test_known_dupes_still_tracked(self):
|
||||
"""Ensure the known-dupe set stays current (remove entries when deduped)."""
|
||||
actual_dupes = {fn for fn, files in self.func_map.items() if len(files) > 1}
|
||||
stale = (KNOWN_CROSS_FILE_DUPES | KNOWN_SAME_FILE_DUPES) - actual_dupes
|
||||
assert not stale, (
|
||||
f"These known duplicates were resolved — remove from KNOWN_*_DUPES:\n"
|
||||
+ "\n".join(f" {fn}" for fn in sorted(stale))
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group D — onclick Handler Coverage
|
||||
# =========================================================================
|
||||
|
||||
class TestOnclickCoverage:
|
||||
"""Every onclick="fn(…)" in index.html should have a matching
|
||||
function declaration in the combined split modules."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan(self):
|
||||
# Collect all function declarations from split modules
|
||||
self.all_fns: set[str] = set()
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
self.all_fns.update(_all_function_decls(text))
|
||||
|
||||
# Also include non-split JS files that are loaded
|
||||
for extra in ("setup-wizard.js", "docs.js", "helper.js"):
|
||||
path = _STATIC / extra
|
||||
if path.exists():
|
||||
self.all_fns.update(_all_function_decls(_read(path)))
|
||||
|
||||
# Extract all onclick function references from HTML
|
||||
html = _read(_INDEX)
|
||||
self.onclick_fns: set[str] = set()
|
||||
for onclick_val in _ONCLICK_RE.findall(html):
|
||||
m = _ONCLICK_FN_RE.match(onclick_val.strip())
|
||||
if m:
|
||||
fn_name = m.group(1)
|
||||
# Skip JS keywords that happen to match (if, return, etc.)
|
||||
if fn_name not in ("if", "return", "var", "let", "const", "this"):
|
||||
self.onclick_fns.add(fn_name)
|
||||
|
||||
def test_all_onclick_handlers_defined(self):
|
||||
missing = self.onclick_fns - self.all_fns
|
||||
assert not missing, (
|
||||
f"onclick handlers reference undefined functions:\n"
|
||||
+ "\n".join(f" {fn}" for fn in sorted(missing))
|
||||
)
|
||||
|
||||
def test_onclick_count_sanity(self):
|
||||
"""Sanity check: there should be a substantial number of onclick handlers."""
|
||||
assert len(self.onclick_fns) > 50, (
|
||||
f"Only found {len(self.onclick_fns)} onclick handlers — expected 100+"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — No Dangerous Cross-File window.X = X Assignments
|
||||
# =========================================================================
|
||||
|
||||
class TestNoCrossFileWindowAssignments:
|
||||
"""window.X = X at the top level of a module is only safe if X is
|
||||
defined in that same module. If X lives in a later-loading module,
|
||||
this causes a ReferenceError at parse time."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan(self):
|
||||
self.module_fns: dict[str, set[str]] = {}
|
||||
self.window_assigns: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
self.module_fns[module] = set(_all_function_decls(text))
|
||||
for prop, value in _WINDOW_ASSIGN_RE.findall(text):
|
||||
self.window_assigns[module].append((prop, value))
|
||||
|
||||
def test_no_cross_file_references(self):
|
||||
bad = []
|
||||
for module, assigns in self.window_assigns.items():
|
||||
local_fns = self.module_fns[module]
|
||||
for prop, value in assigns:
|
||||
if value not in local_fns:
|
||||
bad.append(f" {module}: window.{prop} = {value} "
|
||||
f"('{value}' not declared in {module})")
|
||||
assert not bad, (
|
||||
"Cross-file window.X = X assignments found (will cause ReferenceError):\n"
|
||||
+ "\n".join(bad)
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group F — Module Size Sanity
|
||||
# =========================================================================
|
||||
|
||||
class TestModuleSizes:
|
||||
"""No single module should be unreasonably large (regression guard)."""
|
||||
|
||||
MAX_LINES = 15000 # generous; largest module (wishlist-tools) is ~7200
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_module_size(self, module):
|
||||
text = _read(_STATIC / module)
|
||||
lines = text.count("\n") + 1
|
||||
assert lines < self.MAX_LINES, (
|
||||
f"{module} has {lines} lines (max {self.MAX_LINES})"
|
||||
)
|
||||
|
||||
def test_total_lines_reasonable(self):
|
||||
"""Combined split modules should be in the same ballpark as the original."""
|
||||
total = 0
|
||||
for module in SPLIT_MODULES:
|
||||
total += _read(_STATIC / module).count("\n") + 1
|
||||
# The original was ~78K lines; allow 60K-100K for flexibility
|
||||
assert 50000 < total < 120000, f"Total lines: {total}"
|
||||
Loading…
Reference in a new issue