| 1 | import re |
| 2 | from pathlib import Path |
| 3 | |
| 4 | |
| 5 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 6 | WEBUI_ROOT = PROJECT_ROOT / "webui" |
| 7 | PLUGINS_ROOT = PROJECT_ROOT / "plugins" |
| 8 | LEGACY_ICON_SPAN = re.compile( |
| 9 | r"<span\b(?=[^>]*\b(?:material-symbols-outlined|material-icons-outlined)\b)", |
| 10 | re.IGNORECASE, |
| 11 | ) |
| 12 | |
| 13 | |
| 14 | def first_party_icon_sources() -> list[Path]: |
| 15 | roots = [WEBUI_ROOT] |
| 16 | roots.extend( |
| 17 | path |
| 18 | for path in sorted(PLUGINS_ROOT.iterdir()) |
| 19 | if path.is_dir() and path.name.startswith("_") |
| 20 | ) |
| 21 | return [ |
| 22 | path |
| 23 | for root in roots |
| 24 | for path in root.rglob("*") |
| 25 | if path.is_file() |
| 26 | and path.suffix in {".html", ".js"} |
| 27 | and not {"vendor", "node_modules"}.intersection(path.relative_to(root).parts) |
| 28 | ] |
| 29 | |
| 30 | |
| 31 | def iter_x_icons(source: str): |
| 32 | cursor = 0 |
| 33 | while True: |
| 34 | start = source.find("<x-icon", cursor) |
| 35 | if start < 0: |
| 36 | return |
| 37 | |
| 38 | quote = "" |
| 39 | end = start + len("<x-icon") |
| 40 | while end < len(source): |
| 41 | char = source[end] |
| 42 | if quote: |
| 43 | if char == quote and source[end - 1] != "\\": |
| 44 | quote = "" |
| 45 | elif char in {'"', "'"}: |
| 46 | quote = char |
| 47 | elif char == ">": |
| 48 | break |
| 49 | end += 1 |
| 50 | |
| 51 | close = source.find("</x-icon>", end + 1) |
| 52 | assert end < len(source) and close >= 0 |
| 53 | yield source[start : end + 1], source[end + 1 : close] |
| 54 | cursor = close + len("</x-icon>") |
| 55 | |
| 56 | |
| 57 | def test_first_party_webui_uses_empty_named_x_icon_elements() -> None: |
| 58 | legacy_offenders: list[str] = [] |
| 59 | malformed: list[str] = [] |
| 60 | icon_count = 0 |
| 61 | |
| 62 | for path in first_party_icon_sources(): |
| 63 | source = path.read_text(encoding="utf-8") |
| 64 | if LEGACY_ICON_SPAN.search(source): |
| 65 | legacy_offenders.append(str(path.relative_to(PROJECT_ROOT))) |
| 66 | |
| 67 | for start_tag, content in iter_x_icons(source): |
| 68 | icon_count += 1 |
| 69 | if ( |
| 70 | content.strip() |
| 71 | or re.search(r"\sx-text\s*=", start_tag) |
| 72 | or not re.search(r"(?:^|\s)(?::name|name)\s*=", start_tag) |
| 73 | ): |
| 74 | malformed.append( |
| 75 | f"{path.relative_to(PROJECT_ROOT)}: {start_tag[:160]}" |
| 76 | ) |
| 77 | |
| 78 | assert icon_count >= 500 |
| 79 | assert legacy_offenders == [] |
| 80 | assert malformed == [] |
| 81 | |
| 82 | |
| 83 | def test_x_icon_is_font_backed_and_keeps_legacy_plugin_compatibility() -> None: |
| 84 | icons_js = (WEBUI_ROOT / "js" / "icons.js").read_text(encoding="utf-8") |
| 85 | icon_css = (WEBUI_ROOT / "vendor" / "google" / "google-icons.css").read_text( |
| 86 | encoding="utf-8" |
| 87 | ) |
| 88 | index_html = (WEBUI_ROOT / "index.html").read_text(encoding="utf-8") |
| 89 | |
| 90 | assert '<script type="module" src="/js/icons.js"></script>' in index_html |
| 91 | assert 'customElements.define(' in icons_js |
| 92 | assert 'return ["name"]' in icons_js |
| 93 | assert 'this.classList.add("material-symbols-outlined")' in icons_js |
| 94 | assert ".material-symbols-outlined" in icons_js |
| 95 | assert ".material-icons-outlined" in icons_js |
| 96 | assert "maskImage" not in icons_js |
| 97 | assert "/vendor/icons/" not in icons_js |
| 98 | |
| 99 | assert "x-icon," in icon_css |
| 100 | assert ".material-symbols-outlined," in icon_css |
| 101 | assert ".material-icons-outlined" in icon_css |
| 102 | assert "width: 1em !important" in icon_css |
| 103 | assert "overflow: hidden !important" in icon_css |
| 104 | assert ( |
| 105 | "document.fonts.load('24px \"Material Symbols Outlined\"')" |
| 106 | in index_html |
| 107 | ) |
| 108 | assert ( |
| 109 | 'document.addEventListener("DOMContentLoaded", loadMaterialIcons, { once: true })' |
| 110 | in index_html |
| 111 | ) |