mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 11:02:11 +00:00
feat(licenses_collector): implement license collection for Python and Node dependencies, including metadata extraction and frontend license handling
This commit is contained in:
1
meshchatx/src/backend/data/licenses_frontend.json
Normal file
1
meshchatx/src/backend/data/licenses_frontend.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
223
meshchatx/src/backend/licenses_collector.py
Normal file
223
meshchatx/src/backend/licenses_collector.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Collect third-party license metadata for Python (backend) and Node (frontend)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tomllib
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
_ROOT_DIST_CANDIDATES = ("reticulum-meshchatx", "reticulum_meshchatx")
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
import meshchatx
|
||||
|
||||
return Path(meshchatx.__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _license_from_metadata(meta: importlib.metadata.Metadata) -> str:
|
||||
le = meta.get("License-Expression")
|
||||
if le:
|
||||
return str(le).strip()
|
||||
lic = meta.get("License")
|
||||
if lic and str(lic).strip() and str(lic).strip().upper() != "UNKNOWN":
|
||||
return str(lic).strip()
|
||||
classifiers = meta.get_all("Classifier") or []
|
||||
for line in classifiers:
|
||||
if line.startswith("License ::"):
|
||||
return line.split("::", 1)[-1].strip()
|
||||
return "—"
|
||||
|
||||
|
||||
def _author_from_metadata(meta: importlib.metadata.Metadata) -> str:
|
||||
a = (meta.get("Author") or "").strip()
|
||||
if a:
|
||||
return a
|
||||
ae = (meta.get("Author-email") or "").strip()
|
||||
if ae:
|
||||
return ae
|
||||
m = (meta.get("Maintainer") or "").strip()
|
||||
if m:
|
||||
return m
|
||||
return "—"
|
||||
|
||||
|
||||
def _dist_for_requirement_name(name: str) -> importlib.metadata.Distribution | None:
|
||||
key = canonicalize_name(name)
|
||||
try:
|
||||
return importlib.metadata.distribution(key)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
pass
|
||||
try:
|
||||
return importlib.metadata.distribution(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_python_transitive(root_names: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def visit(name: str) -> None:
|
||||
cname = canonicalize_name(name)
|
||||
if cname in seen:
|
||||
return
|
||||
dist = _dist_for_requirement_name(name)
|
||||
if dist is None:
|
||||
return
|
||||
seen.add(cname)
|
||||
meta = dist.metadata
|
||||
pkg_name = meta.get("Name") or name
|
||||
rows.append(
|
||||
{
|
||||
"name": str(pkg_name),
|
||||
"version": dist.version,
|
||||
"author": _author_from_metadata(meta),
|
||||
"license": _license_from_metadata(meta),
|
||||
},
|
||||
)
|
||||
for req_str in dist.requires or []:
|
||||
if not (req_str or "").strip():
|
||||
continue
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
except Exception:
|
||||
continue
|
||||
if req.extras and not req.marker:
|
||||
pass
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue
|
||||
visit(req.name)
|
||||
|
||||
for root in root_names:
|
||||
visit(root)
|
||||
|
||||
rows.sort(key=lambda r: r["name"].lower())
|
||||
return rows
|
||||
|
||||
|
||||
def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]:
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if not pyproject.is_file():
|
||||
return _ROOT_DIST_CANDIDATES[:1]
|
||||
try:
|
||||
with pyproject.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except OSError:
|
||||
return _ROOT_DIST_CANDIDATES[:1]
|
||||
deps = (data.get("project") or {}).get("dependencies") or []
|
||||
names: list[str] = []
|
||||
for line in deps:
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except Exception:
|
||||
continue
|
||||
names.append(req.name)
|
||||
if not names:
|
||||
return _ROOT_DIST_CANDIDATES[:1]
|
||||
return tuple(sorted(set(names), key=lambda n: n.lower()))
|
||||
|
||||
|
||||
def collect_backend_licenses() -> list[dict[str, Any]]:
|
||||
for root in _ROOT_DIST_CANDIDATES:
|
||||
if _dist_for_requirement_name(root) is not None:
|
||||
return _collect_python_transitive((root,))
|
||||
repo = _repo_root()
|
||||
roots = _python_roots_from_pyproject(repo)
|
||||
return _collect_python_transitive(roots)
|
||||
|
||||
|
||||
def _flatten_pnpm_licenses_json(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for _license_key, packages in data.items():
|
||||
if not isinstance(packages, list):
|
||||
continue
|
||||
for pkg in packages:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
name = pkg.get("name") or "?"
|
||||
versions = pkg.get("versions") or []
|
||||
version = versions[0] if versions else "?"
|
||||
author = pkg.get("author") or "—"
|
||||
if not isinstance(author, str):
|
||||
author = str(author)
|
||||
lic = pkg.get("license") or _license_key or "—"
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": str(version),
|
||||
"author": author,
|
||||
"license": str(lic),
|
||||
},
|
||||
)
|
||||
out.sort(key=lambda r: r["name"].lower())
|
||||
return out
|
||||
|
||||
|
||||
def _load_embedded_frontend_licenses() -> list[dict[str, Any]] | None:
|
||||
data_dir = Path(__file__).resolve().parent / "data"
|
||||
path = data_dir / "licenses_frontend.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
return [x for x in raw if isinstance(x, dict)]
|
||||
|
||||
|
||||
def collect_frontend_licenses() -> tuple[list[dict[str, Any]], str]:
|
||||
embedded = _load_embedded_frontend_licenses()
|
||||
repo = _repo_root()
|
||||
if not (repo / "package.json").is_file():
|
||||
if embedded is not None:
|
||||
return embedded, "embedded"
|
||||
return [], "none"
|
||||
|
||||
pnpm = shutil.which("pnpm")
|
||||
if pnpm:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[pnpm, "licenses", "list", "--json"],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
parsed = json.loads(proc.stdout)
|
||||
if isinstance(parsed, dict):
|
||||
return _flatten_pnpm_licenses_json(parsed), "pnpm"
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if embedded is not None:
|
||||
return embedded, "embedded"
|
||||
|
||||
return [], "none"
|
||||
|
||||
|
||||
def build_licenses_payload() -> dict[str, Any]:
|
||||
backend = collect_backend_licenses()
|
||||
frontend, fe_source = collect_frontend_licenses()
|
||||
return {
|
||||
"backend": backend,
|
||||
"frontend": frontend,
|
||||
"meta": {
|
||||
"generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
"backend_count": len(backend),
|
||||
"frontend_count": len(frontend),
|
||||
"frontend_source": fe_source,
|
||||
},
|
||||
}
|
||||
308
meshchatx/src/frontend/components/licenses/LicensesPage.vue
Normal file
308
meshchatx/src/frontend/components/licenses/LicensesPage.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-white dark:bg-zinc-950">
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-y-auto overscroll-y-contain w-full">
|
||||
<div class="shrink-0 border-b border-gray-200 dark:border-zinc-800 px-3 sm:px-4 md:px-6 py-4 md:py-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-6">
|
||||
<div class="space-y-2 min-w-0 flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("licenses.section_label") }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xl sm:text-2xl md:text-3xl font-black text-gray-900 dark:text-white tracking-tight"
|
||||
>
|
||||
{{ $t("licenses.title") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl">
|
||||
{{ $t("licenses.description") }}
|
||||
</div>
|
||||
<p v-if="meta?.generated_at" class="text-xs text-gray-500 dark:text-zinc-500 break-words">
|
||||
{{ $t("licenses.generated_at", { time: meta.generated_at }) }}
|
||||
<span v-if="meta.frontend_source" class="ml-2 inline-block sm:inline">
|
||||
({{ $t("licenses.frontend_source", { source: meta.frontend_source }) }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:max-w-md xl:max-w-sm shrink-0">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('licenses.search_placeholder')"
|
||||
class="w-full min-h-[44px] sm:min-h-0 pl-10 pr-10 py-3 bg-gray-50 dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center min-w-[44px] justify-end text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 min-h-0 p-3 sm:p-4 md:p-6 xl:p-8 w-full max-w-6xl xl:max-w-[min(100%,96rem)] mx-auto flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
v-if="loadError"
|
||||
class="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 px-4 py-3 text-sm text-red-800 dark:text-red-200"
|
||||
>
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-16 gap-3 text-gray-500 dark:text-zinc-400"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="loading" class="size-10 animate-spin" />
|
||||
<span>{{ $t("common.loading") }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="license-grid grid grid-cols-1 gap-4 xl:grid-cols-2 xl:gap-6 xl:items-start">
|
||||
<details
|
||||
class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
|
||||
open
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
|
||||
>
|
||||
<span class="min-w-0 break-words pr-2"
|
||||
>{{ $t("licenses.backend_section") }} ({{ filteredBackend.length }})</span
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="chevron-down"
|
||||
class="license-details-chevron size-5 shrink-0 opacity-60"
|
||||
/>
|
||||
</summary>
|
||||
<div
|
||||
class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
|
||||
>
|
||||
<table class="min-w-full text-left border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
|
||||
>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_package") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
|
||||
{{ $t("licenses.col_version") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_author") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_license") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in filteredBackend"
|
||||
:key="'b-' + row.name + row.version"
|
||||
class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
|
||||
>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
|
||||
>
|
||||
{{ row.name }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
|
||||
>
|
||||
{{ row.version }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
|
||||
:title="row.author"
|
||||
>
|
||||
{{ row.author }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
|
||||
>
|
||||
{{ row.license }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p
|
||||
v-if="filteredBackend.length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
|
||||
>
|
||||
{{ $t("common.no_results") }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details
|
||||
class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
|
||||
open
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
|
||||
>
|
||||
<span class="min-w-0 break-words pr-2"
|
||||
>{{ $t("licenses.frontend_section") }} ({{ filteredFrontend.length }})</span
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="chevron-down"
|
||||
class="license-details-chevron size-5 shrink-0 opacity-60"
|
||||
/>
|
||||
</summary>
|
||||
<div
|
||||
class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
|
||||
>
|
||||
<table class="min-w-full text-left border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
|
||||
>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_package") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
|
||||
{{ $t("licenses.col_version") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_author") }}
|
||||
</th>
|
||||
<th class="py-2 px-2 sm:px-3 font-medium">
|
||||
{{ $t("licenses.col_license") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in filteredFrontend"
|
||||
:key="'f-' + row.name + row.version"
|
||||
class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
|
||||
>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
|
||||
>
|
||||
{{ row.name }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
|
||||
>
|
||||
{{ row.version }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
|
||||
:title="row.author"
|
||||
>
|
||||
{{ row.author }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
|
||||
>
|
||||
{{ row.license }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p
|
||||
v-if="filteredFrontend.length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
|
||||
>
|
||||
{{ $t("common.no_results") }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "LicensesPage",
|
||||
components: { MaterialDesignIcon },
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
loadError: null,
|
||||
searchQuery: "",
|
||||
backend: [],
|
||||
frontend: [],
|
||||
meta: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
q() {
|
||||
return this.searchQuery.trim().toLowerCase();
|
||||
},
|
||||
filteredBackend() {
|
||||
return this.filterRows(this.backend);
|
||||
},
|
||||
filteredFrontend() {
|
||||
return this.filterRows(this.frontend);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.load();
|
||||
},
|
||||
methods: {
|
||||
filterRows(rows) {
|
||||
if (!this.q) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((r) => {
|
||||
const blob = `${r.name} ${r.version} ${r.author} ${r.license}`.toLowerCase();
|
||||
return blob.includes(this.q);
|
||||
});
|
||||
},
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.loadError = null;
|
||||
try {
|
||||
const res = await window.api.get("/api/v1/licenses");
|
||||
this.backend = res.data.backend || [];
|
||||
this.frontend = res.data.frontend || [];
|
||||
this.meta = res.data.meta || null;
|
||||
} catch (e) {
|
||||
this.loadError = e.response?.data?.error || e.message || "Failed to load licenses";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.license-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.license-details summary::marker {
|
||||
display: none;
|
||||
}
|
||||
.license-details[open] .license-details-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.license-details-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.license-details-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
||||
77
tests/backend/test_licenses_api.py
Normal file
77
tests/backend/test_licenses_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import RNS
|
||||
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
dir_path = tempfile.mkdtemp()
|
||||
yield dir_path
|
||||
shutil.rmtree(dir_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rns_minimal():
|
||||
with (
|
||||
patch("RNS.Reticulum") as mock_rns,
|
||||
patch("RNS.Transport"),
|
||||
patch("LXMF.LXMRouter"),
|
||||
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
|
||||
):
|
||||
mock_rns_instance = mock_rns.return_value
|
||||
mock_rns_instance.configpath = "/tmp/mock_config"
|
||||
mock_rns_instance.is_connected_to_shared_instance = False
|
||||
mock_rns_instance.transport_enabled.return_value = True
|
||||
|
||||
mock_id = MagicMock(spec=RNS.Identity)
|
||||
mock_id.hash = b"test_hash_32_bytes_long_01234567"
|
||||
mock_id.hexhash = mock_id.hash.hex()
|
||||
mock_id.get_private_key.return_value = b"test_private_key"
|
||||
yield mock_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_licenses_endpoint_returns_json(mock_rns_minimal, temp_dir):
|
||||
payload = {
|
||||
"backend": [
|
||||
{"name": "aiohttp", "version": "1", "author": "x", "license": "Apache-2.0"}
|
||||
],
|
||||
"frontend": [],
|
||||
"meta": {
|
||||
"generated_at": "2020-01-01T00:00:00Z",
|
||||
"backend_count": 1,
|
||||
"frontend_count": 0,
|
||||
"frontend_source": "none",
|
||||
},
|
||||
}
|
||||
with (
|
||||
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||
patch(
|
||||
"meshchatx.src.backend.licenses_collector.build_licenses_payload",
|
||||
return_value=payload,
|
||||
),
|
||||
):
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=mock_rns_minimal,
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
|
||||
handler = None
|
||||
for route in app_instance.get_routes():
|
||||
if route.path == "/api/v1/licenses" and route.method == "GET":
|
||||
handler = route.handler
|
||||
break
|
||||
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
response = await handler(request)
|
||||
assert response.status == 200
|
||||
data = json.loads(response.body)
|
||||
assert data == payload
|
||||
60
tests/backend/test_licenses_collector.py
Normal file
60
tests/backend/test_licenses_collector.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from meshchatx.src.backend.licenses_collector import (
|
||||
_flatten_pnpm_licenses_json,
|
||||
build_licenses_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_flatten_pnpm_licenses_json_maps_and_sorts():
|
||||
data = {
|
||||
"MIT": [
|
||||
{
|
||||
"name": "zebra-pkg",
|
||||
"versions": ["2.0.0"],
|
||||
"author": "Z",
|
||||
"license": "MIT",
|
||||
},
|
||||
],
|
||||
"Apache-2.0": [
|
||||
{"name": "alpha-pkg", "versions": ["1.0.0"], "author": "Alice"},
|
||||
{"name": "no-version", "versions": [], "author": "—"},
|
||||
],
|
||||
}
|
||||
rows = _flatten_pnpm_licenses_json(data)
|
||||
assert [r["name"] for r in rows] == ["alpha-pkg", "no-version", "zebra-pkg"]
|
||||
alpha = next(r for r in rows if r["name"] == "alpha-pkg")
|
||||
assert alpha["version"] == "1.0.0"
|
||||
assert alpha["author"] == "Alice"
|
||||
assert alpha["license"] == "Apache-2.0"
|
||||
nov = next(r for r in rows if r["name"] == "no-version")
|
||||
assert nov["version"] == "?"
|
||||
|
||||
|
||||
def test_flatten_pnpm_licenses_json_non_dict_package_skipped():
|
||||
data = {"MIT": ["not-a-dict", {"name": "ok", "versions": ["1"], "author": "x"}]}
|
||||
rows = _flatten_pnpm_licenses_json(data)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "ok"
|
||||
|
||||
|
||||
def test_build_licenses_payload_composes_counts_and_meta():
|
||||
be = [{"name": "rns", "version": "1", "author": "a", "license": "MIT"}]
|
||||
fe = [{"name": "vue", "version": "3", "author": "b", "license": "MIT"}]
|
||||
with (
|
||||
patch(
|
||||
"meshchatx.src.backend.licenses_collector.collect_backend_licenses",
|
||||
return_value=be,
|
||||
),
|
||||
patch(
|
||||
"meshchatx.src.backend.licenses_collector.collect_frontend_licenses",
|
||||
return_value=(fe, "pnpm"),
|
||||
),
|
||||
):
|
||||
payload = build_licenses_payload()
|
||||
assert payload["backend"] == be
|
||||
assert payload["frontend"] == fe
|
||||
assert payload["meta"]["backend_count"] == 1
|
||||
assert payload["meta"]["frontend_count"] == 1
|
||||
assert payload["meta"]["frontend_source"] == "pnpm"
|
||||
assert payload["meta"]["generated_at"].endswith("Z")
|
||||
78
tests/frontend/LicensesPage.test.js
Normal file
78
tests/frontend/LicensesPage.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import LicensesPage from "@/components/licenses/LicensesPage.vue";
|
||||
|
||||
window.api = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
describe("LicensesPage.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads licenses from the API and renders rows", async () => {
|
||||
window.api.get.mockResolvedValue({
|
||||
data: {
|
||||
backend: [{ name: "alpha-be", version: "1.0.0", author: "A", license: "MIT" }],
|
||||
frontend: [{ name: "zebra-fe", version: "2.0.0", author: "Z", license: "Apache-2.0" }],
|
||||
meta: {
|
||||
generated_at: "2020-01-01T00:00:00Z",
|
||||
frontend_source: "pnpm",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(LicensesPage, {
|
||||
global: {
|
||||
mocks: { $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) },
|
||||
stubs: {
|
||||
MaterialDesignIcon: {
|
||||
template: '<span class="mdi-stub" />',
|
||||
props: ["iconName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(window.api.get).toHaveBeenCalledWith("/api/v1/licenses");
|
||||
expect(wrapper.text()).toContain("alpha-be");
|
||||
expect(wrapper.text()).toContain("zebra-fe");
|
||||
});
|
||||
|
||||
it("filters both sections with the search query", async () => {
|
||||
window.api.get.mockResolvedValue({
|
||||
data: {
|
||||
backend: [{ name: "keep-be", version: "1", author: "x", license: "MIT" }],
|
||||
frontend: [{ name: "other-fe", version: "2", author: "y", license: "MIT" }],
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(LicensesPage, {
|
||||
global: {
|
||||
mocks: { $t: (key) => key },
|
||||
stubs: {
|
||||
MaterialDesignIcon: {
|
||||
template: '<span class="mdi-stub" />',
|
||||
props: ["iconName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('input[type="search"]').setValue("keep-be");
|
||||
expect(wrapper.vm.filteredBackend.length).toBe(1);
|
||||
expect(wrapper.vm.filteredFrontend.length).toBe(0);
|
||||
|
||||
await wrapper.find('input[type="search"]').setValue("other-fe");
|
||||
expect(wrapper.vm.filteredBackend.length).toBe(0);
|
||||
expect(wrapper.vm.filteredFrontend.length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user