feat(rrc): improve room message retrieval with pagination support and sequence tracking for messages

This commit is contained in:
Ivan 2026-07-04 16:41:15 -05:00
parent e657ead479
commit 5ddca1c4ec
No known key found for this signature in database
25 changed files with 2621 additions and 79 deletions

View file

@ -79,7 +79,9 @@ Thumbs.db
# Docker files
Dockerfile*
docker-compose*.yml
docker-compose.override.yml
.dockerignore
meshchat-config/
# Local storage and runtime data
storage/
@ -99,11 +101,33 @@ telemetry_test_lxmf/
.env.*.local
.coverage
coverage/
coverage-electron/
.pytest_cache/
htmlcov/
.mutmut-cache
mutmut-stats/
test-results/
playwright-report/
blob-report/
# Local benchmark / CI artifact downloads
bench_results.json
bench_results.txt
/cache/
.artifacts/
# Test suites and reports (not used in container image build)
tests/
# Desktop, CI, and packaging trees not used in Docker image
electron/
.github/
packaging/
scripts/private/
scripts/ci/
# Host-built micron WASM binaries (Docker/build fetch fresh copies)
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
@ -111,11 +135,6 @@ meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
.hypothesis
.hypothesis/
# Arch Linux packaging artifacts
/packaging/arch/src/
/packaging/arch/pkg/
/packaging/arch/*.pkg.tar.zst
/packaging/arch/MeshChatX/
/packaging/arch/reticulum-meshchatx/
cosign.key
*.exe

14
.gitignore vendored
View file

@ -106,15 +106,29 @@ cosign.key
# Docker (local compose overrides)
docker-compose.override.yml
meshchat-config/
.coverage
coverage/
coverage-electron/
.pytest_cache/
htmlcov/
.mutmut-cache
mutmut-stats/
test-results/
playwright-report/
blob-report/
# Local benchmark / CI artifact downloads
bench_results.json
bench_results.txt
/cache/
.artifacts/
# Docker runtime smoke (optional local config dir)
meshchatx-docker-smoke-config/
meshchat-config/
# micron-parser-go WASM (fetched at build via scripts/fetch-micron-wasm.mjs)

View file

@ -13,18 +13,33 @@ All notable changes to this project will be documented in this file.
- **Archives / RNCP / messages**: Rich HTML link clicks use a shared navigation policy so **http(s)** links open externally and mesh links stay in-app (same behavior as Nomad Network and the micron editor preview).
- **Android**: External **http(s)** links open in the system browser instead of being silently blocked in the WebView.
- **RNSh**: Session startup prefers **`python -m RNS.Utilities.rnsh.rnsh`** over the pip **`rnsh`** console-script wrapper so sessions work when the wrapper is not executable (common with **`pip install --user`**) or when Landlock denies executing paths outside allowed read roots (e.g. **`~/.local/bin/rnsh`**). Fixes [#36](https://github.com/Quad4-Software/MeshChatX/issues/36).
- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads; stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode); attaching to an already-running **`rnsd`** shared instance was unaffected.
- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads. Stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode). Attaching to an already-running **`rnsd`** shared instance was unaffected.
- **Messages / stranger banner**: The **Add to Contacts** button on the stranger-protection banner now uses the CSRF-aware **`window.api`** client instead of raw **`fetch`**, so adding a contact from the banner works again. It also checks for an existing contact before creating a duplicate, and shows a success or error toast for the result. Fixes [#38](https://github.com/Quad4-Software/MeshChatX/issues/38).
- **Android / QR scanning**: Contact, message, and paper-message scanners now use a shared camera helper with higher-resolution constraints, optional continuous autofocus, and a **jsQR** canvas fallback when the WebView **`BarcodeDetector`** API is missing or unreliable. Camera permission is requested at Android startup. Fixes [#41](https://github.com/Quad4-Software/MeshChatX/issues/41).
- **macOS**: Universal DMG startup no longer fails with **`ImportError: cannot import name '_add_newdoc_ufunc' from 'numpy._core._multiarray_umath'`** or **`incompatible architecture (have 'arm64', need 'x86_64')`** on NumPy. The x64 cx_Freeze slice now installs locked dependencies via **`uv sync --python-platform x86_64-apple-darwin`** (matching the arm64 slice) instead of unpinned **`pip install -e .`**, and the universal merge step refuses to mix mismatched pure-Python files with native extensions under the same package.
- **UI / sidebars**: Collapsed state for the main navigation sidebar and feature sidebars (Messages, Nomad Network, Relay Chat) now persists in localStorage when switching pages or returning later.
- **Desktop / crash recovery**: The Electron crash screen now diagnoses database corruption, lists local automatic backups, can restore a backup offline, relaunch with auto-repair or emergency mode, and includes copyable instructions for completely removing MeshChatX and Reticulum data folders.
- **Database backups**: Creating a manual backup no longer bundles the just-created backup **`.zip`** into its own archive when the file is written inside the identity storage directory.
- **Notifications**: The notification bell now refreshes immediately when a conversation is marked as read from the conversation view or via bulk mark-as-read in Messages, instead of waiting on the next 5-second poll or the next dropdown open.
- **Bots**: Error and process log text in the Bots tool no longer picks up a stray line break from the closing **`<pre>`** tag formatting.
### Added
- **Messages / images**: Conversation images show a hover download control and support right-click **Save image to device** in the full-screen preview, in addition to the existing message context menu action.
- **Docs**: README troubleshooting section for database corruption, backup restore, and full data removal on Linux, macOS, and Windows.
- **Rich HTML links**: Shared **`NomadRichHtmlLinks`** click handler for **v-html** surfaces (Nomad browser, micron editor preview, archives, RNCP, conversation messages). Routes Nomad mesh and LXMF links in-app, opens **http(s)** externally, scrolls in-page anchors, and blocks unsafe **`javascript:`** navigation. Behavior contract tests guard wiring across frontend, Electron, and Android.
- **Location / telemetry**: Settings include a **Disabled** location source so outbound telemetry replies are not sent until the operator opts in. New installs default to **Disabled** instead of browser geolocation.
- **Docs**: **`scripts/sync-meshchatx-docs.js`** copies **`docs/*.md`** into the in-app **`meshchatx-docs`** bundle on **`predev`** and **`prebuild-frontend`**. Adds the **Meta Quest (SideQuest)** installation guide.
- **Android**: Vendored a patched **bleak** pure-python wheel (unsupported platform-specific backend requirements stripped from its metadata) so **RNode** interfaces can use Bluetooth LE on Android builds.
### Changed
- **Tests**: Removed obsolete no-op backend tests; strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization.
- **Docs**: README clarifies that Linux AppImage and `.deb` packages require **glibc** (not Alpine/PostmarketOS/musl) and documents **`APPIMAGE_EXTRACT_AND_RUN`** for FUSE-less launches. Fixes [#40](https://github.com/Quad4-Software/MeshChatX/issues/40).
- **Tests**: Removed obsolete no-op backend tests. Strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization, plus an API client usage guard test that fails if a mutating request bypasses the CSRF-aware **`window.api`** client. New **`task test:quick`** / **`test:quick:be`** / **`test:quick:fe`** commands run a fast backend and frontend regression subset, and CI runs a thin **Playwright** smoke suite (`tests/e2e/smoke.spec.js`) against the backend and Vite dev server.
- **Packaging**: Arch **`PKGBUILD`** installs Python dependencies with **`uv sync`** instead of a bare **`uv pip install`** step.
- **CI**: GitHub Actions workflows were reorganized around shared composite actions (**`fetch-frontend-artifact`**, **`setup-dev-environment`**, **`setup-python-uv`**), the CodeQL workflow was renamed to **`security.yml`**, and Dependabot now also tracks weekly GitHub Actions updates grouped by publisher.
- **Dependencies**: Bumped **Vue** to **3.5.39**, **Vuetify** to **3.12.8**, **electron-builder** to **26.15.3**, **Playwright** to **1.61.1**, **prettier** to **3.9.3**, and other minor frontend/backend package versions. Relaxed the **dompurify** version constraint to **`>=3.4.11`** and added an **`app-builder-lib>ajv`** override to keep transitive **ajv** versions compatible.
## [4.7.1] - 2026-06-21

View file

@ -7899,6 +7899,9 @@ class ReticulumMeshChat:
return web.json_response({"error": str(e)}, status=500)
# Reticulum Relay Chat
RRC_ROOM_MESSAGES_DEFAULT_LIMIT = 200
RRC_ROOM_MESSAGES_MAX_LIMIT = 1000
def _rrc_require_manager():
manager = self.rrc_manager
if manager is None:
@ -8105,12 +8108,28 @@ class ReticulumMeshChat:
return error
room = request.match_info.get("room", "")
try:
messages = hub.room_messages(room)
limit = int(request.query.get("limit", RRC_ROOM_MESSAGES_DEFAULT_LIMIT))
except (TypeError, ValueError):
limit = RRC_ROOM_MESSAGES_DEFAULT_LIMIT
limit = max(1, min(limit, RRC_ROOM_MESSAGES_MAX_LIMIT))
before_seq_raw = request.query.get("before_seq")
before_seq = None
if before_seq_raw not in (None, ""):
try:
before_seq = int(before_seq_raw)
except (TypeError, ValueError):
before_seq = None
try:
messages, has_more = hub.room_messages(
room, limit=limit, before_seq=before_seq,
)
members = hub.members_dict(room)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
manager.set_active(hub, room)
return web.json_response({"messages": messages, "members": members})
return web.json_response(
{"messages": messages, "members": members, "has_more": has_more},
)
@routes.post("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages")
async def rrc_hub_send_message(request):

View file

@ -102,6 +102,12 @@ class RRCHub:
self._silent_joins = set()
self._history_write_failed = False
self._seq_counter = 0
def _next_seq(self):
with self._lock:
self._seq_counter += 1
return self._seq_counter
def _log(self, msg, level=None):
if level is None:
@ -795,6 +801,7 @@ class RRCHub:
continue
if filter_msgs and m.kind in ("system", "notice"):
continue
m.seq = self._next_seq()
msgs.append(m)
with self._lock:
self.messages[room] = msgs
@ -824,6 +831,7 @@ class RRCHub:
def _record_message(self, msg, local=False):
cap = self._per_room_cap()
with self._lock:
msg.seq = self._next_seq()
buf = self.messages.setdefault(msg.room or "*", [])
buf.append(msg)
if cap is not None and len(buf) > cap:
@ -846,6 +854,7 @@ class RRCHub:
msg = proto.RRCMessage("system", room, None, None, text, proto.now_ms())
cap = self._per_room_cap()
with self._lock:
msg.seq = self._next_seq()
buf = self.messages.setdefault(room, [])
buf.append(msg)
if cap is not None and len(buf) > cap:
@ -863,6 +872,7 @@ class RRCHub:
cap = self._per_room_cap()
with self._lock:
msg.seq = self._next_seq()
self.notices.append(msg)
if len(self.notices) > 200:
del self.notices[: len(self.notices) - 200]
@ -1342,9 +1352,22 @@ class RRCHub:
"max_msg_body_bytes": self.max_msg_body_bytes,
}
def room_messages(self, room):
"""Return serialized messages for a room."""
return [m.to_dict() for m in self.get_messages(proto.normalize_room(room))]
def room_messages(self, room, limit=None, before_seq=None):
"""Return ``(messages, has_more)`` for a room, newest page last.
``before_seq``, when given, restricts results to messages recorded
before that sequence number, letting callers page backwards through
history. ``limit`` caps how many of the most recent matching messages
are returned; ``has_more`` reports whether older messages remain.
"""
msgs = self.get_messages(proto.normalize_room(room))
if before_seq is not None:
msgs = [m for m in msgs if (m.seq or 0) < before_seq]
has_more = False
if limit is not None and len(msgs) > limit:
has_more = True
msgs = msgs[-limit:]
return [m.to_dict() for m in msgs], has_more
def members_dict(self, room):
"""Return serialized members for a room."""

View file

@ -274,6 +274,7 @@ class RRCMessage:
self.text = text
self.ts = ts
self.mention = False
self.seq = None
def to_dict(self):
"""Return a JSON-serializable representation of the message."""
@ -286,4 +287,5 @@ class RRCMessage:
"text": self.text if isinstance(self.text, str) else "",
"ts": int(self.ts) if isinstance(self.ts, int) else 0,
"mention": bool(self.mention),
"seq": self.seq,
}

View file

@ -349,7 +349,9 @@
<div class="flex flex-1 min-w-0 flex-col min-h-0">
<div
ref="messageList"
class="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-1.5 sm:p-4"
class="relative flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4"
style="overflow-anchor: none"
@scroll="onMessagesScroll"
>
<div
v-if="!selectedRoom"
@ -358,72 +360,29 @@
<MaterialDesignIcon icon-name="chat-outline" class="size-10 opacity-40" />
{{ $t("relay_chat.no_room_selected") }}
</div>
<template v-else>
<template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
<div
v-if="entry.type === 'dateDivider'"
class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
role="separator"
:aria-label="formatDateDividerLabel(entry.dayKey)"
>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
<span
class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
>
{{ formatDateDividerLabel(entry.dayKey) }}
</span>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
</div>
<template v-else-if="entry.msg">
<div
v-if="
entry.msg.kind === 'system' ||
entry.msg.kind === 'notice' ||
entry.msg.kind === 'error'
"
class="py-0.5 text-center text-xs italic"
:class="
entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'
"
:data-msg-key="messageKey(entry.msg)"
>
{{ entry.msg.text }}
</div>
<div
v-else-if="entry.msg.kind === 'action'"
class="rounded-lg px-2 py-1 text-sm italic"
:class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
:data-msg-key="messageKey(entry.msg)"
>
<span class="mr-1 text-xs text-sem-fg-muted">{{
formatTime(entry.msg.ts)
}}</span>
* {{ displayName(entry.msg) }} {{ entry.msg.text }}
</div>
<div
v-else
class="rounded-lg px-2 py-1 text-sm"
:class="
entry.msg.mention
? 'bg-sem-warning/15'
: 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'
"
:data-msg-key="messageKey(entry.msg)"
@contextmenu.prevent="openMessageContextMenu($event, entry.msg)"
>
<span class="mr-1.5 text-xs text-sem-fg-muted">{{
formatTime(entry.msg.ts)
}}</span>
<span class="mr-1.5 font-semibold" :style="nameStyle(entry.msg)"
>{{ displayName(entry.msg) }}:</span
>
<span class="whitespace-pre-wrap break-words">{{
entry.msg.text
}}</span>
</div>
<div v-else class="relative min-w-0" :class="useVirtualMessageList ? '' : 'space-y-1.5'">
<button
v-show="!isLoadingPrevious && hasMorePrevious"
type="button"
class="absolute top-0 left-1/2 z-20 -translate-x-1/2 flex items-center gap-1.5 rounded-full border border-sem-border bg-sem-canvas/95 px-3 py-1 text-xs font-medium text-sem-fg-secondary shadow-xs backdrop-blur-sm hover:bg-sem-surface/60"
@click="loadPreviousMessages"
>
<MaterialDesignIcon icon-name="arrow-up" class="size-3.5" />
{{ $t("relay_chat.load_previous") }}
</button>
<template v-if="!useVirtualMessageList">
<template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
<RelayMessageEntry :entry="entry" :page="relayChatPageSelf" />
</template>
</template>
</template>
<RelayMessageListVirtual
v-else
ref="messageListVirtual"
:entries="messageTimeline"
:get-scroll-element="getMessagesScrollElement"
:page="relayChatPageSelf"
/>
</div>
</div>
<form
@ -1103,12 +1062,15 @@ import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconName
import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
import RelayMessageEntry from "./RelayMessageEntry.vue";
import RelayMessageListVirtual from "./RelayMessageListVirtual.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
@ -1128,6 +1090,9 @@ const BTN_DANGER_SM =
"inline-flex items-center justify-center rounded-lg border border-sem-border bg-sem-canvas p-1.5 text-sem-fg transition hover:border-sem-danger hover:text-sem-danger hover:bg-sem-danger/10";
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
const RELAY_MESSAGES_INITIAL_PAGE_SIZE = 150;
const RELAY_MESSAGES_PREVIOUS_PAGE_SIZE = 100;
const LOAD_PREVIOUS_SCROLL_EDGE_PX = 200;
export default {
name: "RelayChatPage",
@ -1135,6 +1100,8 @@ export default {
MaterialDesignIcon,
MdiIconPickerModal,
RelayHostModerationPage,
RelayMessageEntry,
RelayMessageListVirtual,
ContextMenuPanel,
ContextMenuItem,
ContextMenuDivider,
@ -1205,6 +1172,10 @@ export default {
},
messages: [],
members: [],
hasMorePrevious: false,
isLoadingPrevious: false,
loadPreviousInFlight: 0,
roomSelectSequence: 0,
composer: "",
sending: false,
joinRoomName: "",
@ -1269,6 +1240,27 @@ export default {
messageTimeline() {
return buildRelayMessageTimeline(this.messages);
},
relayChatPageSelf() {
return this;
},
useVirtualMessageList() {
if (this.messageTimeline.length < MIN_VIRTUAL_RELAY_ENTRIES) {
return false;
}
return GlobalState?.config?.message_list_virtualization !== false;
},
oldestLoadedSeq() {
let minSeq = null;
for (const msg of this.messages) {
if (typeof msg.seq !== "number") {
continue;
}
if (minSeq === null || msg.seq < minSeq) {
minSeq = msg.seq;
}
}
return minSeq;
},
searchResults() {
return filterRelayMessages(this.messages, this.messageSearch, (msg) => this.displayName(msg));
},
@ -1829,6 +1821,10 @@ export default {
}
this.showSearch = false;
const key = this.messageKey(msg);
if (this.useVirtualMessageList) {
nextTick(() => this.$refs.messageListVirtual?.scrollToMessageKey(key));
return;
}
nextTick(() => {
const el = this.$refs.messageList?.querySelector(`[data-msg-key="${key}"]`);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
@ -1898,22 +1894,89 @@ export default {
this.selectedHubHash = hubHash;
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
this.hasMorePrevious = false;
const seq = ++this.roomSelectSequence;
try {
const response = await window.api.get(
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
{ params: { limit: RELAY_MESSAGES_INITIAL_PAGE_SIZE } }
);
if (seq !== this.roomSelectSequence) {
return;
}
this.messages = response.data?.messages || [];
this.members = response.data?.members || [];
this.hasMorePrevious = Boolean(response.data?.has_more);
this.scrollToBottom();
if (!options.restore) {
await this.fetchHubs();
}
this.persistRelayLayout();
} catch {
if (seq !== this.roomSelectSequence) {
return;
}
this.messages = [];
this.members = [];
this.hasMorePrevious = false;
}
},
async loadPreviousMessages() {
if (this.isLoadingPrevious || !this.hasMorePrevious || !this.selectedHubHash || !this.selectedRoom) {
return;
}
const beforeSeq = this.oldestLoadedSeq;
if (beforeSeq === null) {
this.hasMorePrevious = false;
return;
}
const seq = this.roomSelectSequence;
const hubHash = this.selectedHubHash;
const room = this.selectedRoom;
this.loadPreviousInFlight += 1;
this.isLoadingPrevious = true;
try {
const response = await window.api.get(
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
{ params: { limit: RELAY_MESSAGES_PREVIOUS_PAGE_SIZE, before_seq: beforeSeq } }
);
if (seq !== this.roomSelectSequence || hubHash !== this.selectedHubHash || room !== this.selectedRoom) {
return;
}
const older = response.data?.messages || [];
this.hasMorePrevious = Boolean(response.data?.has_more);
if (older.length === 0) {
return;
}
const scrollEl = this.useVirtualMessageList ? null : this.$refs.messageList;
const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0;
const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0;
this.messages = [...older, ...this.messages];
if (scrollEl) {
nextTick(() => {
const delta = scrollEl.scrollHeight - prevScrollHeight;
scrollEl.scrollTop = prevScrollTop + delta;
});
}
} catch {
this.hasMorePrevious = false;
} finally {
this.loadPreviousInFlight = Math.max(0, this.loadPreviousInFlight - 1);
this.isLoadingPrevious = this.loadPreviousInFlight > 0;
}
},
onMessagesScroll(event) {
const el = event.target;
if (!el || this.isLoadingPrevious || !this.hasMorePrevious) {
return;
}
if (el.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX) {
this.loadPreviousMessages();
}
},
getMessagesScrollElement() {
return this.$refs.messageList ?? null;
},
async refreshMembers() {
if (!this.selectedHubHash || !this.selectedRoom) {
return;
@ -1929,6 +1992,10 @@ export default {
},
scrollToBottom() {
nextTick(() => {
if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
this.$refs.messageListVirtual.scrollToBottom();
return;
}
const el = this.$refs.messageList;
if (el) {
el.scrollTop = el.scrollHeight;

View file

@ -0,0 +1,66 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div
v-if="entry.type === 'dateDivider'"
class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
role="separator"
:aria-label="page.formatDateDividerLabel(entry.dayKey)"
>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
<span
class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
>
{{ page.formatDateDividerLabel(entry.dayKey) }}
</span>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
</div>
<div
v-else-if="isSystemLike"
class="py-0.5 text-center text-xs italic"
:class="entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'"
:data-msg-key="page.messageKey(entry.msg)"
>
{{ entry.msg.text }}
</div>
<div
v-else-if="entry.msg && entry.msg.kind === 'action'"
class="rounded-lg px-2 py-1 text-sm italic"
:class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
:data-msg-key="page.messageKey(entry.msg)"
>
<span class="mr-1 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
* {{ page.displayName(entry.msg) }} {{ entry.msg.text }}
</div>
<div
v-else-if="entry.msg"
class="rounded-lg px-2 py-1 text-sm"
:class="entry.msg.mention ? 'bg-sem-warning/15' : 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'"
:data-msg-key="page.messageKey(entry.msg)"
@contextmenu.prevent="page.openMessageContextMenu($event, entry.msg)"
>
<span class="mr-1.5 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
<span class="mr-1.5 font-semibold" :style="page.nameStyle(entry.msg)">{{ page.displayName(entry.msg) }}:</span>
<span class="whitespace-pre-wrap break-words">{{ entry.msg.text }}</span>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
entry: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
});
const isSystemLike = computed(() => {
const kind = props.entry?.msg?.kind;
return kind === "system" || kind === "notice" || kind === "error";
});
</script>

View file

@ -0,0 +1,87 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="relative w-full shrink-0" :style="{ height: totalSize + 'px' }">
<div
v-for="v in virtualItems"
:key="entryKey(entries[v.index], v.index)"
:ref="measureElement"
:data-index="v.index"
class="absolute left-0 top-0 w-full box-border px-0 [overflow-anchor:none]"
:style="{ transform: `translateY(${v.start}px)` }"
>
<RelayMessageEntry :entry="entries[v.index]" :page="page" />
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import RelayMessageEntry from "./RelayMessageEntry.vue";
import { estimateRelayEntryHeight, findRelayEntryIndexForMessageKey } from "./relayMessageListVirtual.js";
const props = defineProps({
entries: {
type: Array,
required: true,
},
getScrollElement: {
type: Function,
required: true,
},
page: {
type: Object,
required: true,
},
});
const virtualizer = useVirtualizer(
computed(() => ({
count: props.entries.length,
getScrollElement: () => props.getScrollElement() ?? null,
estimateSize: (index) => estimateRelayEntryHeight(props.entries[index]),
overscan: 12,
}))
);
const virtualItems = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize());
function entryKey(entry, index) {
if (!entry) {
return index;
}
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}`;
}
return props.page.messageKey(entry.msg);
}
function measureElement(el) {
if (el) {
virtualizer.value.measureElement(el);
}
}
function scrollToMessageKey(key) {
const idx = findRelayEntryIndexForMessageKey(props.entries, key, props.page.messageKey);
if (idx < 0) {
return;
}
virtualizer.value.scrollToIndex(idx, { align: "center", behavior: "smooth" });
}
function scrollToBottom() {
const n = props.entries.length;
if (n === 0) {
return;
}
virtualizer.value.scrollToIndex(n - 1, { align: "end", behavior: "auto" });
}
defineExpose({
scrollToMessageKey,
scrollToBottom,
});
</script>

View file

@ -0,0 +1,40 @@
export const MIN_VIRTUAL_RELAY_ENTRIES = 150;
/**
* Initial row height guess before measureElement runs (variable-height rows).
* @param {unknown} entry
* @returns {number}
*/
export function estimateRelayEntryHeight(entry) {
if (!entry || typeof entry !== "object") {
return 28;
}
if (entry.type === "dateDivider") {
return 44;
}
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
let height = 28;
if (text) {
height += Math.ceil(text.length / 80) * 18;
}
return height;
}
/**
* @param {unknown[]} entries
* @param {string} key
* @param {(msg: object) => string} keyFn
* @returns {number}
*/
export function findRelayEntryIndexForMessageKey(entries, key, keyFn) {
if (!entries?.length || !key || typeof keyFn !== "function") {
return -1;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry?.type === "message" && entry.msg && keyFn(entry.msg) === key) {
return i;
}
}
return -1;
}

View file

@ -2264,6 +2264,7 @@
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"no_room_selected": "Select a room to view messages.",
"load_previous": "Load earlier messages",
"rooms": "Rooms",
"members": "Members",
"join_room": "Join Room",

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Build the standard Docker image and verify /api/v1/status over HTTPS inside the container.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
IMAGE="${MESHCHAT_DOCKER_SMOKE_IMAGE:-meshchatx-runtime-smoke:local}"
CONTAINER="${MESHCHAT_DOCKER_SMOKE_CONTAINER:-meshchatx-runtime-smoke}"
HOST_PORT="${MESHCHAT_DOCKER_SMOKE_PORT:-18080}"
CONFIG_DIR="${MESHCHAT_DOCKER_SMOKE_CONFIG:-$(mktemp -d)}"
TIMEOUT_SEC="${MESHCHAT_DOCKER_SMOKE_TIMEOUT:-180}"
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
if [ "${MESHCHAT_DOCKER_SMOKE_KEEP_CONFIG:-0}" != "1" ] && [ -n "${TMP_CONFIG_CREATED:-}" ]; then
rm -rf "$CONFIG_DIR"
fi
}
trap cleanup EXIT
if [ "$CONFIG_DIR" = "$(mktemp -u)" ] || [ ! -d "$CONFIG_DIR" ]; then
:
fi
if [ -z "${MESHCHAT_DOCKER_SMOKE_CONFIG:-}" ]; then
TMP_CONFIG_CREATED=1
fi
echo "Building Docker image ${IMAGE}..."
docker build -f Dockerfile -t "$IMAGE" .
cleanup
mkdir -p "$CONFIG_DIR"
echo "Starting container on host port ${HOST_PORT}..."
docker run -d \
--name "$CONTAINER" \
-p "${HOST_PORT}:8000" \
-v "${CONFIG_DIR}:/config" \
"$IMAGE" >/dev/null
deadline=$((SECONDS + TIMEOUT_SEC))
status_ok=0
while [ "$SECONDS" -lt "$deadline" ]; do
if curl -fsSk "https://127.0.0.1:${HOST_PORT}/api/v1/status" >/tmp/meshchatx-docker-status.json 2>/dev/null; then
if python3 -c 'import json,sys; d=json.load(open("/tmp/meshchatx-docker-status.json")); sys.exit(0 if d.get("status")=="ok" else 1)'; then
status_ok=1
break
fi
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "Container exited early. Logs:" >&2
docker logs "$CONTAINER" >&2 || true
exit 1
fi
sleep 2
done
if [ "$status_ok" != "1" ]; then
echo "Timed out waiting for https://127.0.0.1:${HOST_PORT}/api/v1/status" >&2
docker logs "$CONTAINER" >&2 || true
exit 1
fi
echo "Docker runtime smoke passed: $(cat /tmp/meshchatx-docker-status.json)"

View file

@ -0,0 +1,61 @@
{
"client_inbound": [
"config.set",
"keyboard_shortcuts.delete",
"keyboard_shortcuts.get",
"keyboard_shortcuts.set",
"lxm.generate_paper_uri",
"lxm.ingest_uri",
"lxmf.forwarding.rule.add",
"lxmf.forwarding.rule.delete",
"lxmf.forwarding.rule.toggle",
"lxmf.forwarding.rules.get",
"nomadnet.download.cancel",
"nomadnet.file.download",
"nomadnet.page.archive.add",
"nomadnet.page.archive.flush",
"nomadnet.page.archive.load",
"nomadnet.page.archives.get",
"nomadnet.page.download",
"ping"
],
"client_direct_responses": [
"keyboard_shortcuts",
"lxm.generate_paper_uri.result",
"lxm.ingest_uri.result",
"lxmf.forwarding.rules",
"nomadnet.download.cancelled",
"nomadnet.file.download",
"nomadnet.page.archive.added",
"nomadnet.page.archives",
"nomadnet.page.download",
"pong"
],
"server_broadcast": [
"announce",
"announced",
"blocked_destinations",
"config",
"identity_switched",
"lxmf.delivery",
"lxmf.telemetry",
"lxmf_message_created",
"lxmf_message_deleted",
"lxmf_message_state_updated",
"new_voicemail",
"reticulum_reload_status",
"rncp.fetch.completed",
"rncp.send.completed",
"rncp.transfer.progress",
"rnsh.output",
"rnsh.session.change",
"rrc.change",
"rrc.message",
"rrc.server.change",
"telephone_call_ended",
"telephone_call_established",
"telephone_initiation_status",
"telephone_missed_call",
"telephone_ringing"
]
}

View file

@ -0,0 +1,110 @@
# SPDX-License-Identifier: 0BSD
"""Shared runtime helpers for HTTP JSON response contract tests."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@dataclass(frozen=True)
class HttpJsonContract:
method: str
path: str
schema: dict
match_info: dict[str, str] = field(default_factory=dict)
query: dict[str, str] = field(default_factory=dict)
allow_statuses: tuple[int, ...] = (200,)
alt_schemas: tuple[dict, ...] = ()
class _Query:
def __init__(self, data: dict[str, str] | None = None):
self._data = data or {}
def get(self, key, default=None):
return self._data.get(key, default)
def make_contract_app(temp_dir: str, mock_identity: MagicMock) -> ReticulumMeshChat:
return ReticulumMeshChat(
identity=mock_identity,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
def make_minimal_identity() -> MagicMock:
mock_id = MagicMock(spec=RNS.Identity)
mock_id.hash = b"test_hash_32_bytes_long_01234567"
mock_id.hexhash = mock_id.hash.hex()
key = b"public_key_bytes_32_chars_long!!"
mock_id.get_private_key.return_value = key
mock_id.get_public_key.return_value = key
return mock_id
def bootstrap_contract_app(app: ReticulumMeshChat) -> ReticulumMeshChat:
ctx = app.current_context
if ctx is None:
return app
lxmf_dest = MagicMock()
lxmf_dest.hexhash = "cc" * 16
ctx.local_lxmf_destination = lxmf_dest
if getattr(app, "message_router", None) is not None:
prop = MagicMock()
prop.hexhash = "dd" * 16
app.message_router.propagation_destination = prop
if getattr(ctx, "telephone_manager", None) is not None:
telephone = getattr(ctx.telephone_manager, "telephone", None)
if telephone is not None:
dest = MagicMock()
dest.hexhash = "ee" * 16
telephone.destination = dest
return app
def find_route_handler(app: ReticulumMeshChat, path: str, method: str):
for route in app.get_routes():
if route.path == path and route.method == method:
return route.handler
return None
def build_request(
*,
match_info: dict[str, str] | None = None,
query: dict[str, str] | None = None,
) -> MagicMock:
request = MagicMock()
request.match_info = match_info or {}
request.query = _Query(query)
return request
async def invoke_json_contract(
app: ReticulumMeshChat,
contract: HttpJsonContract,
) -> tuple[int, Any]:
handler = find_route_handler(app, contract.path, contract.method)
if handler is None:
raise AssertionError(
f"No handler for {contract.method} {contract.path}",
)
request = build_request(
match_info=contract.match_info,
query=contract.query,
)
if contract.method in {"POST", "PATCH", "PUT", "DELETE"}:
request.json = AsyncMock(return_value={})
response = await handler(request)
status = response.status
body = json.loads(response.body)
return status, body

View file

@ -0,0 +1,425 @@
# SPDX-License-Identifier: 0BSD
"""Registry of HTTP JSON response contracts for GET /api/v1 routes."""
from __future__ import annotations
import re
from tests.backend.api_json_contract_schemas import (
API_V1_APP_INFO_ENVELOPE_SCHEMA,
API_V1_STATUS_SCHEMA,
AUTH_STATUS_SCHEMA,
TELEPHONE_CONTACT_CHECK_SCHEMA,
TELEPHONE_CONTACTS_LIST_SCHEMA,
TELEPHONE_RINGTONE_STATUS_SCHEMA,
TELEPHONE_RINGTONES_LIST_SCHEMA,
TELEPHONE_VOICEMAIL_STATUS_SCHEMA,
TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
)
from tests.backend.http_api_contract_runtime import HttpJsonContract
from tests.backend.http_api_response_schemas import (
ACCESS_ATTEMPTS_SCHEMA,
ANNOUNCE_SINGLE_SCHEMA,
ANNOUNCES_LIST_SCHEMA,
BLACKHOLE_STATUS_SCHEMA,
BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA,
BOTS_STATUS_SCHEMA,
BOTS_SUBPROCESS_LOG_SCHEMA,
CHANGELOG_SCHEMA,
COMPORTS_ENVELOPE_SCHEMA,
COMMUNITY_INTERFACES_SCHEMA,
CONFIG_ENVELOPE_SCHEMA,
CSRF_ENVELOPE_SCHEMA,
DATABASE_BACKUPS_SCHEMA,
DATABASE_HEALTH_SCHEMA,
DATABASE_SNAPSHOTS_SCHEMA,
DEBUG_LOGS_SCHEMA,
DESTINATION_DISPLAY_NAME_SCHEMA,
DESTINATION_PATH_SCHEMA,
DESTINATION_SIGNAL_METRICS_SCHEMA,
DESTINATION_STAMP_INFO_SCHEMA,
DISCOVERED_INTERFACES_SCHEMA,
DISCOVERY_CONFIG_SCHEMA,
DOCS_SEARCH_SCHEMA,
DOCS_STATUS_SCHEMA,
ERROR_ENVELOPE_SCHEMA,
FAVOURITES_SCHEMA,
GIFS_LIST_SCHEMA,
IDENTITIES_LIST_SCHEMA,
IDENTITY_BACKUP_BASE32_SCHEMA,
INTERFACE_STATS_SCHEMA,
INTERFACES_LIST_SCHEMA,
LICENSES_ENVELOPE_SCHEMA,
LXMF_CONVERSATION_MESSAGES_SCHEMA,
LXMF_CONVERSATION_PINS_SCHEMA,
LXMF_CONVERSATIONS_SCHEMA,
LXMF_FOLDERS_SCHEMA,
LXMF_MESSAGE_BLOCKLIST_SCHEMA,
LXMF_MESSAGE_URI_SCHEMA,
LXMF_PROPAGATION_NODES_SCHEMA,
LXMF_PROPAGATION_STATUS_SCHEMA,
LXMF_SIEVE_FILTERS_SCHEMA,
MAP_DRAWINGS_SCHEMA,
MAP_MBTILES_SCHEMA,
MAP_OFFLINE_SCHEMA,
MEMORY_DIAGNOSTICS_SCHEMA,
MESHCHATX_DOCS_CONTENT_SCHEMA,
MESHCHATX_DOCS_LIST_SCHEMA,
NOMADNET_ARCHIVES_SCHEMA,
NOTIFICATIONS_SCHEMA,
PAGE_NODE_DETAIL_SCHEMA,
PAGE_NODE_FILES_SCHEMA,
PAGE_NODE_PAGES_SCHEMA,
PAGE_NODES_LIST_SCHEMA,
PATH_TABLE_SCHEMA,
REPOSITORY_SERVER_LIST_SCHEMA,
REPOSITORY_SERVER_STATUS_SCHEMA,
RETICULUM_CONFIG_RAW_SCHEMA,
RNCP_STATUS_SCHEMA,
RNCP_TRANSFER_SCHEMA,
RNPATH_RATES_SCHEMA,
RNPATH_TABLE_SCHEMA,
RNPATH_TRACE_SCHEMA,
RNSH_OUTPUT_SCHEMA,
RNSH_SESSIONS_SCHEMA,
RNSTATUS_SCHEMA,
RRC_ACTIVITY_SCHEMA,
RRC_HUBS_SCHEMA,
RRC_MEMBERS_SCHEMA,
RRC_MESSAGES_SCHEMA,
RRC_SERVERS_SCHEMA,
SERVER_SECURITY_SCHEMA,
SPAM_KEYWORDS_SCHEMA,
STICKER_PACK_DETAIL_SCHEMA,
STICKER_PACKS_LIST_SCHEMA,
STICKERS_LIST_SCHEMA,
SYSTEM_NETWORK_INTERFACES_SCHEMA,
TELEMETRY_HISTORY_SCHEMA,
TELEMETRY_LATEST_SCHEMA,
TELEMETRY_PEERS_SCHEMA,
TELEMETRY_TRACKING_SCHEMA,
TELEMETRY_TRUSTED_PEERS_SCHEMA,
TELEPHONE_AUDIO_PROFILES_SCHEMA,
TELEPHONE_CALL_SCHEMA,
TELEPHONE_HISTORY_SCHEMA,
TELEPHONE_RECORDINGS_SCHEMA,
TELEPHONE_STATUS_SCHEMA,
TOOLS_MICRON_PARSER_RELEASE_SCHEMA,
TOOLS_RNODE_LATEST_RELEASE_SCHEMA,
TRANSLATOR_LANGUAGES_SCHEMA,
)
_HEX32 = "a" * 32
_HEX16 = "b" * 16
_NODE_ID = "1"
_ROOM = "lobby"
_HUB_ID = "1"
_SESSION_ID = "1"
_TRANSFER_ID = "1"
_PAGE_NAME = "index.mu"
HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
HttpJsonContract("GET", "/api/v1/status", API_V1_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/app/info", API_V1_APP_INFO_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/app/changelog", CHANGELOG_SCHEMA),
HttpJsonContract("GET", "/api/v1/auth/status", AUTH_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/auth/csrf", CSRF_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/server/security", SERVER_SECURITY_SCHEMA),
HttpJsonContract("GET", "/api/v1/config", CONFIG_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/blocked-destinations", BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/comports", COMPORTS_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/system/network-interfaces", SYSTEM_NETWORK_INTERFACES_SCHEMA),
HttpJsonContract("GET", "/api/v1/reticulum/interfaces", INTERFACES_LIST_SCHEMA),
HttpJsonContract("GET", "/api/v1/community-interfaces", COMMUNITY_INTERFACES_SCHEMA),
HttpJsonContract("GET", "/api/v1/reticulum/discovery", DISCOVERY_CONFIG_SCHEMA),
HttpJsonContract("GET", "/api/v1/reticulum/discovered-interfaces", DISCOVERED_INTERFACES_SCHEMA),
HttpJsonContract("GET", "/api/v1/reticulum/config/raw", RETICULUM_CONFIG_RAW_SCHEMA),
HttpJsonContract("GET", "/api/v1/reticulum/blackhole", BLACKHOLE_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/interface-stats", INTERFACE_STATS_SCHEMA),
HttpJsonContract("GET", "/api/v1/path-table", PATH_TABLE_SCHEMA),
HttpJsonContract("GET", "/api/v1/licenses", LICENSES_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/docs/status", DOCS_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/docs/search", DOCS_SEARCH_SCHEMA, query={"q": "reticulum"}),
HttpJsonContract("GET", "/api/v1/meshchatx-docs/list", MESHCHATX_DOCS_LIST_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/meshchatx-docs/content",
MESHCHATX_DOCS_CONTENT_SCHEMA,
query={"path": "meshchatx.md"},
),
HttpJsonContract(
"GET",
"/api/v1/repository-server/status",
REPOSITORY_SERVER_STATUS_SCHEMA,
allow_statuses=(200, 503),
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
),
HttpJsonContract(
"GET",
"/api/v1/repository-server/list",
REPOSITORY_SERVER_LIST_SCHEMA,
allow_statuses=(200, 503),
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/database/health", DATABASE_HEALTH_SCHEMA),
HttpJsonContract("GET", "/api/v1/database/snapshots", DATABASE_SNAPSHOTS_SCHEMA),
HttpJsonContract("GET", "/api/v1/database/backups", DATABASE_BACKUPS_SCHEMA),
HttpJsonContract("GET", "/api/v1/debug/logs", DEBUG_LOGS_SCHEMA),
HttpJsonContract("GET", "/api/v1/debug/access-attempts", ACCESS_ATTEMPTS_SCHEMA),
HttpJsonContract("GET", "/api/v1/diagnostics/memory", MEMORY_DIAGNOSTICS_SCHEMA),
HttpJsonContract("GET", "/api/v1/diagnostics/memory/heap", MEMORY_DIAGNOSTICS_SCHEMA),
HttpJsonContract("GET", "/api/v1/diagnostics/memory/gc", MEMORY_DIAGNOSTICS_SCHEMA),
HttpJsonContract("GET", "/api/v1/diagnostics/memory/referrers", MEMORY_DIAGNOSTICS_SCHEMA),
HttpJsonContract("GET", "/api/v1/identities", IDENTITIES_LIST_SCHEMA),
HttpJsonContract("GET", "/api/v1/identity/backup/base32", IDENTITY_BACKUP_BASE32_SCHEMA),
HttpJsonContract("GET", "/api/v1/announce", ANNOUNCE_SINGLE_SCHEMA, query={"aspect": "lxmf.delivery"}),
HttpJsonContract("GET", "/api/v1/announces", ANNOUNCES_LIST_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/destination/{destination_hash}/path",
DESTINATION_PATH_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract(
"GET",
"/api/v1/destination/{destination_hash}/custom-display-name",
DESTINATION_DISPLAY_NAME_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract(
"GET",
"/api/v1/destination/{destination_hash}/lxmf-stamp-info",
DESTINATION_STAMP_INFO_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract(
"GET",
"/api/v1/destination/{destination_hash}/signal-metrics",
DESTINATION_SIGNAL_METRICS_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract("GET", "/api/v1/notifications", NOTIFICATIONS_SCHEMA),
HttpJsonContract("GET", "/api/v1/favourites", FAVOURITES_SCHEMA),
HttpJsonContract("GET", "/api/v1/nomadnet/archives", NOMADNET_ARCHIVES_SCHEMA),
HttpJsonContract("GET", "/api/v1/page-nodes", PAGE_NODES_LIST_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/page-nodes/{node_id}",
PAGE_NODE_DETAIL_SCHEMA,
match_info={"node_id": _NODE_ID},
),
HttpJsonContract(
"GET",
"/api/v1/page-nodes/{node_id}/files",
PAGE_NODE_FILES_SCHEMA,
match_info={"node_id": _NODE_ID},
),
HttpJsonContract(
"GET",
"/api/v1/page-nodes/{node_id}/pages",
PAGE_NODE_PAGES_SCHEMA,
match_info={"node_id": _NODE_ID},
),
HttpJsonContract(
"GET",
"/api/v1/page-nodes/{node_id}/pages/{page_name}",
PAGE_NODE_DETAIL_SCHEMA,
match_info={"node_id": _NODE_ID, "page_name": _PAGE_NAME},
),
HttpJsonContract("GET", "/api/v1/lxmf/conversations", LXMF_CONVERSATIONS_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/folders", LXMF_FOLDERS_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/conversation-pins", LXMF_CONVERSATION_PINS_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/sieve-filters", LXMF_SIEVE_FILTERS_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/message-blocklist", LXMF_MESSAGE_BLOCKLIST_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/propagation-nodes", LXMF_PROPAGATION_NODES_SCHEMA),
HttpJsonContract("GET", "/api/v1/lxmf/propagation-node/status", LXMF_PROPAGATION_STATUS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/lxmf-messages/conversation/{destination_hash}",
LXMF_CONVERSATION_MESSAGES_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract(
"GET",
"/api/v1/lxmf-messages/{message_hash}/uri",
LXMF_MESSAGE_URI_SCHEMA,
match_info={"message_hash": _HEX16},
),
HttpJsonContract("GET", "/api/v1/gifs", GIFS_LIST_SCHEMA),
HttpJsonContract("GET", "/api/v1/stickers", STICKERS_LIST_SCHEMA),
HttpJsonContract("GET", "/api/v1/sticker-packs", STICKER_PACKS_LIST_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/sticker-packs/{pack_id}",
STICKER_PACK_DETAIL_SCHEMA,
match_info={"pack_id": _NODE_ID},
),
HttpJsonContract("GET", "/api/v1/map/drawings", MAP_DRAWINGS_SCHEMA),
HttpJsonContract("GET", "/api/v1/map/offline", MAP_OFFLINE_SCHEMA),
HttpJsonContract("GET", "/api/v1/map/mbtiles", MAP_MBTILES_SCHEMA),
HttpJsonContract("GET", "/api/v1/telemetry/peers", TELEMETRY_PEERS_SCHEMA),
HttpJsonContract("GET", "/api/v1/telemetry/tracking", TELEMETRY_TRACKING_SCHEMA),
HttpJsonContract("GET", "/api/v1/telemetry/trusted-peers", TELEMETRY_TRUSTED_PEERS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/telemetry/latest/{destination_hash}",
TELEMETRY_LATEST_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract(
"GET",
"/api/v1/telemetry/history/{destination_hash}",
TELEMETRY_HISTORY_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract("GET", "/api/v1/rrc/hubs", RRC_HUBS_SCHEMA),
HttpJsonContract("GET", "/api/v1/rrc/servers", RRC_SERVERS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages",
RRC_MESSAGES_SCHEMA,
match_info={"hub_hash": _HEX32, "room": _ROOM},
),
HttpJsonContract(
"GET",
"/api/v1/rrc/servers/{hub_id}/members",
RRC_MEMBERS_SCHEMA,
match_info={"hub_id": _HUB_ID},
),
HttpJsonContract(
"GET",
"/api/v1/rrc/servers/{hub_id}/activity",
RRC_ACTIVITY_SCHEMA,
match_info={"hub_id": _HUB_ID},
),
HttpJsonContract(
"GET",
"/api/v1/rrc/servers/{hub_id}/messages",
RRC_MESSAGES_SCHEMA,
match_info={"hub_id": _HUB_ID},
),
HttpJsonContract("GET", "/api/v1/rncp/status", RNCP_STATUS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/rncp/transfer/{transfer_id}",
RNCP_TRANSFER_SCHEMA,
match_info={"transfer_id": _TRANSFER_ID},
),
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/rnpath/trace/{destination_hash}",
RNPATH_TRACE_SCHEMA,
match_info={"destination_hash": _HEX32},
),
HttpJsonContract("GET", "/api/v1/rnsh/sessions", RNSH_SESSIONS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/rnsh/sessions/{session_id}/output",
RNSH_OUTPUT_SCHEMA,
match_info={"session_id": _SESSION_ID},
),
HttpJsonContract("GET", "/api/v1/rnstatus", RNSTATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/bots/status", BOTS_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/bots/subprocess-log", BOTS_SUBPROCESS_LOG_SCHEMA),
HttpJsonContract("GET", "/api/v1/spam-keywords", SPAM_KEYWORDS_SCHEMA),
HttpJsonContract("GET", "/api/v1/translator/languages", TRANSLATOR_LANGUAGES_SCHEMA),
HttpJsonContract("GET", "/api/v1/tools/micron-parser-go-release", TOOLS_MICRON_PARSER_RELEASE_SCHEMA),
HttpJsonContract("GET", "/api/v1/tools/rnode/latest_release", TOOLS_RNODE_LATEST_RELEASE_SCHEMA),
HttpJsonContract("GET", "/api/v1/telephone/status", TELEPHONE_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/telephone/history", TELEPHONE_HISTORY_SCHEMA),
HttpJsonContract("GET", "/api/v1/telephone/recordings", TELEPHONE_RECORDINGS_SCHEMA),
HttpJsonContract("GET", "/api/v1/telephone/audio-profiles", TELEPHONE_AUDIO_PROFILES_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/telephone/call/{identity_hash}",
TELEPHONE_CALL_SCHEMA,
match_info={"identity_hash": _HEX32},
),
HttpJsonContract("GET", "/api/v1/telephone/voicemail/status", TELEPHONE_VOICEMAIL_STATUS_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/telephone/voicemails",
TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
query={"limit": "50", "offset": "0"},
),
HttpJsonContract("GET", "/api/v1/telephone/ringtones", TELEPHONE_RINGTONES_LIST_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/telephone/ringtones/status",
TELEPHONE_RINGTONE_STATUS_SCHEMA,
query={},
),
HttpJsonContract(
"GET",
"/api/v1/telephone/contacts",
TELEPHONE_CONTACTS_LIST_SCHEMA,
query={"limit": "100", "offset": "0"},
),
HttpJsonContract(
"GET",
"/api/v1/telephone/contacts/check/{identity_hash}",
TELEPHONE_CONTACT_CHECK_SCHEMA,
match_info={"identity_hash": _HEX16},
),
)
HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
"/api/v1/database/backup/download",
"/api/v1/database/backups/{filename}/download",
"/api/v1/database/snapshots/{filename}/download",
"/api/v1/docs/export",
"/api/v1/docs/export/reticulum",
"/api/v1/identity/backup/download",
"/api/v1/identities/export-all",
"/api/v1/maintenance/messages/export",
"/api/v1/bots/export",
"/api/v1/gifs/export",
"/api/v1/lxmf/folders/export",
"/api/v1/lxmf/message-blocklist/export",
"/api/v1/stickers/export",
"/api/v1/sticker-packs/{pack_id}/export",
"/api/v1/telephone/contacts/export",
"/api/v1/tools/rnode/download_firmware",
"/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}",
"/api/v1/gifs/{gif_id}/image",
"/api/v1/stickers/{sticker_id}/image",
"/api/v1/map/tiles/{z}/{x}/{y}",
"/api/v1/map/export/{export_id}",
"/api/v1/map/export/{export_id}/download",
"/api/v1/telephone/recordings/{id}/audio/{side}",
"/api/v1/telephone/ringtones/{id}/audio",
"/api/v1/telephone/voicemail/greeting/audio",
"/api/v1/telephone/voicemails/{id}/audio",
"/api/v1/telephone/answer",
"/api/v1/telephone/hangup",
"/api/v1/telephone/send-to-voicemail",
"/api/v1/telephone/mute-transmit",
"/api/v1/telephone/mute-receive",
"/api/v1/telephone/unmute-transmit",
"/api/v1/telephone/unmute-receive",
"/api/v1/telephone/switch-audio-profile/{profile_id}",
"/api/v1/lxmf/propagation-node/sync",
"/api/v1/lxmf/propagation-node/stop-sync",
"/api/v1/ping/{destination_hash}/lxmf.delivery",
)
_HTTP_JSON_GET_EXCLUDED_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"/download$"),
re.compile(r"/export$"),
re.compile(r"/image$"),
re.compile(r"/audio"),
re.compile(r"/tiles/"),
)
def is_excluded_json_get_route(path: str) -> bool:
if path in HTTP_JSON_GET_CONTRACT_EXCLUDED:
return True
return any(pattern.search(path) for pattern in _HTTP_JSON_GET_EXCLUDED_PATTERNS)
def registered_get_paths() -> set[str]:
return {contract.path for contract in HTTP_JSON_GET_CONTRACTS}

View file

@ -0,0 +1,681 @@
# SPDX-License-Identifier: 0BSD
"""Additional JSON Schema definitions for broad HTTP API contract tests."""
from __future__ import annotations
_OBJECT = {"type": "object", "additionalProperties": True}
_ARRAY = {"type": "array"}
_STRING = {"type": "string"}
_INTEGER = {"type": "integer"}
_BOOLEAN = {"type": "boolean"}
_NUMBER = {"type": "number"}
ERROR_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["error"],
"properties": {"error": _STRING},
"additionalProperties": True,
}
MESSAGE_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["message"],
"properties": {"message": _STRING},
"additionalProperties": True,
}
CONFIG_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["config"],
"properties": {"config": _OBJECT},
"additionalProperties": False,
}
BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["blocked_destinations"],
"properties": {
"blocked_destinations": {
"type": "array",
"items": {
"type": "object",
"required": ["destination_hash", "created_at"],
"properties": {
"destination_hash": _STRING,
"created_at": _STRING,
},
"additionalProperties": True,
},
},
},
"additionalProperties": False,
}
SERVER_SECURITY_SCHEMA: dict = {
"type": "object",
"required": [
"listen_host",
"listen_port",
"https_enabled",
"is_loopback_bind",
"web_ui_ip_allowlist",
"privacy_mode_enabled",
"auth_enabled",
],
"properties": {
"listen_host": {"type": ["string", "null"]},
"listen_port": {"type": ["integer", "null"]},
"https_enabled": _BOOLEAN,
"is_loopback_bind": _BOOLEAN,
"web_ui_ip_allowlist": _STRING,
"privacy_mode_enabled": _BOOLEAN,
"auth_enabled": _BOOLEAN,
},
"additionalProperties": True,
}
CSRF_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["csrf_token"],
"properties": {"csrf_token": _STRING},
"additionalProperties": False,
}
COMPORTS_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["comports"],
"properties": {"comports": _ARRAY},
"additionalProperties": False,
}
INTERFACES_LIST_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
COMMUNITY_INTERFACES_SCHEMA: dict = {
"type": "object",
"required": ["entries"],
"properties": {"entries": _ARRAY},
"additionalProperties": True,
}
IDENTITIES_LIST_SCHEMA: dict = {
"type": "object",
"required": ["identities"],
"properties": {"identities": _ARRAY},
"additionalProperties": True,
}
LICENSES_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["licenses"],
"properties": {"licenses": _ARRAY},
"additionalProperties": True,
}
DOCS_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _STRING},
"additionalProperties": True,
}
DOCS_SEARCH_SCHEMA: dict = {
"type": "object",
"required": ["results"],
"properties": {"results": _ARRAY},
"additionalProperties": True,
}
MESHCHATX_DOCS_LIST_SCHEMA: dict = {
"type": "object",
"required": ["docs"],
"properties": {"docs": _ARRAY},
"additionalProperties": True,
}
MESHCHATX_DOCS_CONTENT_SCHEMA: dict = {
"type": "object",
"required": ["content"],
"properties": {"content": _STRING},
"additionalProperties": True,
}
DATABASE_HEALTH_SCHEMA: dict = {
"type": "object",
"required": ["healthy"],
"properties": {"healthy": _BOOLEAN},
"additionalProperties": True,
}
DATABASE_SNAPSHOTS_SCHEMA: dict = {
"type": "object",
"required": ["snapshots"],
"properties": {"snapshots": _ARRAY},
"additionalProperties": True,
}
DATABASE_BACKUPS_SCHEMA: dict = {
"type": "object",
"required": ["backups"],
"properties": {"backups": _ARRAY},
"additionalProperties": True,
}
DEBUG_LOGS_SCHEMA: dict = {
"type": "object",
"required": ["logs"],
"properties": {"logs": _ARRAY},
"additionalProperties": True,
}
ACCESS_ATTEMPTS_SCHEMA: dict = {
"type": "object",
"required": ["attempts"],
"properties": {"attempts": _ARRAY},
"additionalProperties": True,
}
MEMORY_DIAGNOSTICS_SCHEMA: dict = {
"type": "object",
"required": ["rss_bytes"],
"properties": {"rss_bytes": _INTEGER},
"additionalProperties": True,
}
DISCOVERY_CONFIG_SCHEMA: dict = {
"type": "object",
"required": [
"discover_interfaces",
"interface_discovery_sources",
"interface_discovery_whitelist",
"interface_discovery_blacklist",
"required_discovery_value",
],
"properties": {
"discover_interfaces": {},
"interface_discovery_sources": {},
"interface_discovery_whitelist": {},
"interface_discovery_blacklist": {},
"required_discovery_value": {},
},
"additionalProperties": True,
}
DISCOVERED_INTERFACES_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
RETICULUM_CONFIG_RAW_SCHEMA: dict = {
"type": "object",
"required": ["config"],
"properties": {"config": _STRING},
"additionalProperties": False,
}
BLACKHOLE_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["enabled"],
"properties": {"enabled": _BOOLEAN},
"additionalProperties": True,
}
INTERFACE_STATS_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
PATH_TABLE_SCHEMA: dict = {
"type": "object",
"required": ["paths"],
"properties": {"paths": _ARRAY},
"additionalProperties": True,
}
LXMF_CONVERSATIONS_SCHEMA: dict = {
"type": "object",
"required": ["conversations"],
"properties": {"conversations": _ARRAY},
"additionalProperties": True,
}
LXMF_FOLDERS_SCHEMA: dict = {
"type": "object",
"required": ["folders"],
"properties": {"folders": _ARRAY},
"additionalProperties": True,
}
LXMF_CONVERSATION_PINS_SCHEMA: dict = {
"type": "object",
"required": ["pins"],
"properties": {"pins": _ARRAY},
"additionalProperties": True,
}
LXMF_SIEVE_FILTERS_SCHEMA: dict = {
"type": "object",
"required": ["filters"],
"properties": {"filters": _ARRAY},
"additionalProperties": True,
}
LXMF_MESSAGE_BLOCKLIST_SCHEMA: dict = {
"type": "object",
"required": ["entries"],
"properties": {"entries": _ARRAY},
"additionalProperties": True,
}
LXMF_PROPAGATION_NODES_SCHEMA: dict = {
"type": "object",
"required": ["nodes"],
"properties": {"nodes": _ARRAY},
"additionalProperties": True,
}
LXMF_PROPAGATION_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _STRING},
"additionalProperties": True,
}
NOTIFICATIONS_SCHEMA: dict = {
"type": "object",
"required": ["notifications"],
"properties": {"notifications": _ARRAY},
"additionalProperties": True,
}
FAVOURITES_SCHEMA: dict = {
"type": "object",
"required": ["favourites"],
"properties": {"favourites": _ARRAY},
"additionalProperties": True,
}
PAGE_NODES_LIST_SCHEMA: dict = {
"type": "object",
"required": ["nodes"],
"properties": {"nodes": _ARRAY},
"additionalProperties": True,
}
PAGE_NODE_DETAIL_SCHEMA: dict = {
"type": "object",
"required": ["node"],
"properties": {"node": _OBJECT},
"additionalProperties": True,
}
PAGE_NODE_FILES_SCHEMA: dict = {
"type": "object",
"required": ["files"],
"properties": {"files": _ARRAY},
"additionalProperties": True,
}
PAGE_NODE_PAGES_SCHEMA: dict = {
"type": "object",
"required": ["pages"],
"properties": {"pages": _ARRAY},
"additionalProperties": True,
}
NOMADNET_ARCHIVES_SCHEMA: dict = {
"type": "object",
"required": ["archives"],
"properties": {"archives": _ARRAY},
"additionalProperties": True,
}
GIFS_LIST_SCHEMA: dict = {
"type": "object",
"required": ["gifs"],
"properties": {"gifs": _ARRAY},
"additionalProperties": True,
}
STICKERS_LIST_SCHEMA: dict = {
"type": "object",
"required": ["stickers"],
"properties": {"stickers": _ARRAY},
"additionalProperties": True,
}
STICKER_PACKS_LIST_SCHEMA: dict = {
"type": "object",
"required": ["packs"],
"properties": {"packs": _ARRAY},
"additionalProperties": True,
}
STICKER_PACK_DETAIL_SCHEMA: dict = {
"type": "object",
"required": ["pack"],
"properties": {"pack": _OBJECT},
"additionalProperties": True,
}
MAP_DRAWINGS_SCHEMA: dict = {
"type": "object",
"required": ["drawings"],
"properties": {"drawings": _ARRAY},
"additionalProperties": True,
}
MAP_OFFLINE_SCHEMA: dict = {
"type": "object",
"required": ["tiles"],
"properties": {"tiles": _ARRAY},
"additionalProperties": True,
}
MAP_MBTILES_SCHEMA: dict = {
"type": "object",
"required": ["mbtiles"],
"properties": {"mbtiles": _ARRAY},
"additionalProperties": True,
}
TELEMETRY_PEERS_SCHEMA: dict = {
"type": "object",
"required": ["peers"],
"properties": {"peers": _ARRAY},
"additionalProperties": True,
}
TELEMETRY_TRACKING_SCHEMA: dict = {
"type": "object",
"required": ["tracking"],
"properties": {"tracking": _ARRAY},
"additionalProperties": True,
}
TELEMETRY_TRUSTED_PEERS_SCHEMA: dict = {
"type": "object",
"required": ["trusted_peers"],
"properties": {"trusted_peers": _ARRAY},
"additionalProperties": True,
}
TELEMETRY_LATEST_SCHEMA: dict = {
"type": "object",
"required": ["telemetry"],
"properties": {"telemetry": {"type": ["object", "null"]}},
"additionalProperties": True,
}
TELEMETRY_HISTORY_SCHEMA: dict = {
"type": "object",
"required": ["history"],
"properties": {"history": _ARRAY},
"additionalProperties": True,
}
RRC_HUBS_SCHEMA: dict = {
"type": "object",
"required": ["hubs"],
"properties": {"hubs": _ARRAY},
"additionalProperties": True,
}
RRC_SERVERS_SCHEMA: dict = {
"type": "object",
"required": ["servers"],
"properties": {"servers": _ARRAY},
"additionalProperties": True,
}
RRC_MESSAGES_SCHEMA: dict = {
"type": "object",
"required": ["messages"],
"properties": {"messages": _ARRAY},
"additionalProperties": True,
}
RRC_MEMBERS_SCHEMA: dict = {
"type": "object",
"required": ["members"],
"properties": {"members": _ARRAY},
"additionalProperties": True,
}
RRC_ACTIVITY_SCHEMA: dict = {
"type": "object",
"required": ["activity"],
"properties": {"activity": _ARRAY},
"additionalProperties": True,
}
RNCP_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _STRING},
"additionalProperties": True,
}
RNCP_TRANSFER_SCHEMA: dict = {
"type": "object",
"required": ["transfer"],
"properties": {"transfer": {"type": ["object", "null"]}},
"additionalProperties": True,
}
RNPATH_TABLE_SCHEMA: dict = {
"type": "object",
"required": ["table"],
"properties": {"table": _ARRAY},
"additionalProperties": True,
}
RNPATH_RATES_SCHEMA: dict = {
"type": "object",
"required": ["rates"],
"properties": {"rates": _ARRAY},
"additionalProperties": True,
}
RNPATH_TRACE_SCHEMA: dict = {
"type": "object",
"required": ["trace"],
"properties": {"trace": _ARRAY},
"additionalProperties": True,
}
RNSH_SESSIONS_SCHEMA: dict = {
"type": "object",
"required": ["sessions"],
"properties": {"sessions": _ARRAY},
"additionalProperties": True,
}
RNSH_OUTPUT_SCHEMA: dict = {
"type": "object",
"required": ["output"],
"properties": {"output": _STRING},
"additionalProperties": True,
}
RNSTATUS_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
BOTS_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["bots"],
"properties": {"bots": _ARRAY},
"additionalProperties": True,
}
BOTS_SUBPROCESS_LOG_SCHEMA: dict = {
"type": "object",
"required": ["log"],
"properties": {"log": _STRING},
"additionalProperties": True,
}
SPAM_KEYWORDS_SCHEMA: dict = {
"type": "object",
"required": ["keywords"],
"properties": {"keywords": _ARRAY},
"additionalProperties": True,
}
SYSTEM_NETWORK_INTERFACES_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
TRANSLATOR_LANGUAGES_SCHEMA: dict = {
"type": "object",
"required": ["languages"],
"properties": {"languages": _ARRAY},
"additionalProperties": True,
}
TOOLS_MICRON_PARSER_RELEASE_SCHEMA: dict = {
"type": "object",
"required": ["release"],
"properties": {"release": _STRING},
"additionalProperties": True,
}
TOOLS_RNODE_LATEST_RELEASE_SCHEMA: dict = {
"type": "object",
"required": ["release"],
"properties": {"release": _OBJECT},
"additionalProperties": True,
}
REPOSITORY_SERVER_STATUS_SCHEMA: dict = {
"type": "object",
"additionalProperties": True,
}
REPOSITORY_SERVER_LIST_SCHEMA: dict = {
"type": "object",
"required": ["entries"],
"properties": {"entries": _ARRAY},
"additionalProperties": True,
}
ANNOUNCE_SINGLE_SCHEMA: dict = {
"type": "object",
"required": ["announce"],
"properties": {"announce": {"type": ["object", "null"]}},
"additionalProperties": True,
}
ANNOUNCES_LIST_SCHEMA: dict = {
"type": "object",
"required": ["announces"],
"properties": {"announces": _ARRAY},
"additionalProperties": True,
}
DESTINATION_PATH_SCHEMA: dict = {
"type": "object",
"required": ["path"],
"properties": {"path": {"type": ["object", "null"]}},
"additionalProperties": True,
}
DESTINATION_DISPLAY_NAME_SCHEMA: dict = {
"type": "object",
"required": ["custom_display_name"],
"properties": {"custom_display_name": {"type": ["string", "null"]}},
"additionalProperties": True,
}
DESTINATION_STAMP_INFO_SCHEMA: dict = {
"type": "object",
"required": ["stamp_info"],
"properties": {"stamp_info": {"type": ["object", "null"]}},
"additionalProperties": True,
}
DESTINATION_SIGNAL_METRICS_SCHEMA: dict = {
"type": "object",
"required": ["metrics"],
"properties": {"metrics": {"type": ["object", "null"]}},
"additionalProperties": True,
}
LXMF_CONVERSATION_MESSAGES_SCHEMA: dict = {
"type": "object",
"required": ["messages"],
"properties": {"messages": _ARRAY},
"additionalProperties": True,
}
LXMF_MESSAGE_URI_SCHEMA: dict = {
"type": "object",
"required": ["uri"],
"properties": {"uri": _STRING},
"additionalProperties": True,
}
IDENTITY_BACKUP_BASE32_SCHEMA: dict = {
"type": "object",
"required": ["base32"],
"properties": {"base32": _STRING},
"additionalProperties": True,
}
CHANGELOG_SCHEMA: dict = {
"type": "object",
"required": ["changelog"],
"properties": {"changelog": _STRING},
"additionalProperties": True,
}
TELEPHONE_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _INTEGER},
"additionalProperties": True,
}
TELEPHONE_HISTORY_SCHEMA: dict = {
"type": "object",
"required": ["history"],
"properties": {"history": _ARRAY},
"additionalProperties": True,
}
TELEPHONE_RECORDINGS_SCHEMA: dict = {
"type": "object",
"required": ["recordings"],
"properties": {"recordings": _ARRAY},
"additionalProperties": True,
}
TELEPHONE_AUDIO_PROFILES_SCHEMA: dict = {
"type": "object",
"required": ["profiles"],
"properties": {"profiles": _ARRAY},
"additionalProperties": True,
}
TELEPHONE_CALL_SCHEMA: dict = {
"type": "object",
"required": ["call"],
"properties": {"call": {"type": ["object", "null"]}},
"additionalProperties": True,
}

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: 0BSD
"""Docker image runtime smoke test (build + /api/v1/status)."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
try:
subprocess.run(
["docker", "info"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=30,
)
except (subprocess.SubprocessError, OSError):
return False
return True
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke (build + /api/v1/status)",
)
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
def test_docker_image_serves_status():
env = os.environ.copy()
env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
result = subprocess.run(
["bash", str(_SMOKE_SCRIPT)],
cwd=_REPO_ROOT,
env=env,
capture_output=True,
text=True,
timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 120,
check=False,
)
assert result.returncode == 0, (
f"Docker runtime smoke failed (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke",
)
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
def test_docker_status_json_shape():
"""Lightweight follow-up: status body matches minimal contract when smoke script left output."""
sample = {"status": "ok", "listen_port": 8000, "https_enabled": True}
from tests.backend.api_json_contract_schemas import API_V1_STATUS_SCHEMA, assert_matches_schema
assert_matches_schema(sample, API_V1_STATUS_SCHEMA)

View file

@ -0,0 +1,124 @@
# SPDX-License-Identifier: 0BSD
"""Broad JSON Schema contract tests for GET /api/v1 HTTP handlers."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from tests.backend.api_json_contract_schemas import assert_matches_schema
from tests.backend.http_api_contract_helpers import load_route_fixture
from tests.backend.http_api_contract_runtime import (
HttpJsonContract,
bootstrap_contract_app,
invoke_json_contract,
make_contract_app,
make_minimal_identity,
)
from tests.backend.http_api_response_registry import (
HTTP_JSON_GET_CONTRACTS,
is_excluded_json_get_route,
registered_get_paths,
)
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "http_api_routes.json"
@pytest.fixture(scope="module")
def temp_dir():
import shutil
import tempfile
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture(scope="module")
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
yield make_minimal_identity()
@pytest.fixture(scope="module")
def contract_app(mock_rns_minimal, temp_dir):
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("psutil.Process") as mock_process,
patch("psutil.net_io_counters") as mock_net_io,
patch("importlib.metadata.version", return_value="1.2.3"),
patch("meshchatx.meshchat.LXST") as mock_lxst,
patch("threading.Thread"),
):
mock_lxst.__version__ = "1.2.3"
mock_proc_instance = mock_process.return_value
mock_proc_instance.memory_info.return_value.rss = 1024
mock_proc_instance.memory_info.return_value.vms = 2048
mock_proc_instance.net_connections.return_value = []
mock_net_instance = mock_net_io.return_value
mock_net_instance.bytes_sent = 0
mock_net_instance.bytes_recv = 0
mock_net_instance.packets_sent = 0
mock_net_instance.packets_recv = 0
app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal))
yield app
def _validate_body(contract: HttpJsonContract, status: int, body) -> None:
schemas = (contract.schema, *contract.alt_schemas)
if status in contract.allow_statuses:
for schema in schemas:
try:
assert_matches_schema(body, schema)
return
except Exception:
continue
raise AssertionError(
f"{contract.method} {contract.path} status {status} body did not match "
f"any allowed schema: {json.dumps(body)[:500]}",
)
raise AssertionError(
f"{contract.method} {contract.path} returned unexpected status {status}: "
f"{json.dumps(body)[:500]}",
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"contract",
HTTP_JSON_GET_CONTRACTS,
ids=lambda c: f"{c.method} {c.path}",
)
async def test_get_json_response_matches_schema(contract_app, contract: HttpJsonContract):
status, body = await invoke_json_contract(contract_app, contract)
_validate_body(contract, status, body)
def test_every_json_get_route_is_registered_or_excluded():
routes = load_route_fixture(_FIXTURE)
get_api = [r["path"] for r in routes if r["method"] == "GET" and r["path"].startswith("/api/v1")]
registered = registered_get_paths()
uncovered = []
for path in get_api:
if path in registered:
continue
if is_excluded_json_get_route(path):
continue
uncovered.append(path)
assert not uncovered, (
"GET /api/v1 routes missing from HTTP JSON contract registry "
f"(add schema or exclude): {uncovered}"
)

View file

@ -87,6 +87,7 @@ def test_parse_room_list_notice():
def test_message_to_dict():
msg = proto.RRCMessage("msg", "lobby", b"\xaa\xbb", "bob", "hi", 123)
msg.mention = True
msg.seq = 7
assert msg.to_dict() == {
"kind": "msg",
"room": "lobby",
@ -95,6 +96,7 @@ def test_message_to_dict():
"text": "hi",
"ts": 123,
"mention": True,
"seq": 7,
}

View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: 0BSD
"""WebSocket JSON message contract tests."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshchatx.meshchat import ReticulumMeshChat
from tests.backend.ws_contract_helpers import (
extract_client_direct_response_types,
extract_client_inbound_types,
extract_server_broadcast_types,
load_ws_manifest,
write_ws_manifest,
)
from tests.backend.ws_json_contract_schemas import (
WS_MESSAGE_SAMPLES,
WS_MESSAGE_SCHEMAS,
all_ws_message_types,
assert_ws_message_matches_schema,
)
_REPO_ROOT = Path(__file__).resolve().parents[2]
_MESHCHAT_PY = _REPO_ROOT / "meshchatx" / "meshchat.py"
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "ws_message_manifest.json"
def _all_manifest_types(manifest: dict[str, list[str]]) -> set[str]:
out: set[str] = set()
for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
out.update(manifest.get(key, []))
return out
def test_ws_message_manifest_matches_meshchat():
live = {
"client_inbound": extract_client_inbound_types(_MESHCHAT_PY),
"client_direct_responses": extract_client_direct_response_types(_MESHCHAT_PY),
"server_broadcast": extract_server_broadcast_types(_MESHCHAT_PY),
}
if __import__("os").environ.get("UPDATE_WS_MESSAGE_MANIFEST") == "1":
write_ws_manifest(_FIXTURE, live)
pytest.skip(
"UPDATE_WS_MESSAGE_MANIFEST=1: fixture updated; re-run without the env var",
)
expected = load_ws_manifest(_FIXTURE)
assert live == expected, (
"WebSocket message manifest drifted. Run: "
"UPDATE_WS_MESSAGE_MANIFEST=1 uv run pytest "
"tests/backend/test_ws_json_contracts.py -k manifest_matches_meshchat"
)
def test_every_manifest_type_has_schema():
manifest = load_ws_manifest(_FIXTURE)
missing = sorted(_all_manifest_types(manifest) - all_ws_message_types())
assert not missing, f"Missing WebSocket schemas for: {missing}"
def test_every_schema_has_sample_payload():
missing = sorted(all_ws_message_types() - set(WS_MESSAGE_SAMPLES))
assert not missing, f"Missing WebSocket sample payloads for: {missing}"
@pytest.mark.parametrize("msg_type", sorted(WS_MESSAGE_SCHEMAS))
def test_ws_schema_accepts_documented_sample(msg_type: str):
sample = WS_MESSAGE_SAMPLES[msg_type]
assert sample["type"] == msg_type
assert_ws_message_matches_schema(sample)
@pytest.mark.asyncio
async def test_ws_ping_emits_pong_contract():
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
client = MagicMock()
sent: list[str] = []
async def capture_send(payload: str):
sent.append(payload)
client.send_str = AsyncMock(side_effect=capture_send)
with patch("meshchatx.meshchat.AsyncUtils") as mock_async_utils:
def run_async(coro):
import asyncio
if asyncio.iscoroutine(coro):
asyncio.get_event_loop().create_task(coro)
mock_async_utils.run_async.side_effect = run_async
await app.on_websocket_data_received(client, {"type": "ping"})
for payload in sent:
assert_ws_message_matches_schema(json.loads(payload))
assert json.loads(payload)["type"] == "pong"

View file

@ -0,0 +1,94 @@
# SPDX-License-Identifier: 0BSD
"""Helpers for WebSocket JSON message contract checks."""
from __future__ import annotations
import json
import re
from pathlib import Path
_CLIENT_HANDLER_RE = re.compile(
r"(?:if|elif)\s+_type\s*==\s*[\"']([^\"']+)[\"']",
)
_WS_SEND_STR_RE = re.compile(
r"client\.send_str\s*\(\s*json\.dumps\s*\(\s*(\{[\s\S]*?\})\s*,?\s*\)",
)
_WS_TYPE_LITERAL_RE = re.compile(
r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']",
)
_BROADCAST_CALL_RE = re.compile(
r"(?:websocket_broadcast|_broadcast_websocket_message)\s*\(",
)
def extract_client_inbound_types(meshchat_py: Path) -> list[str]:
text = meshchat_py.read_text(encoding="utf-8")
start = text.find("async def on_websocket_data_received")
if start < 0:
return []
end = text.find("\n async def ", start + 1)
if end < 0:
end = text.find("\n def ", start + 1)
block = text[start:end] if end > start else text[start:]
types = _CLIENT_HANDLER_RE.findall(block)
return sorted(set(types))
def _extract_type_literals_from_dict_literal(blob: str) -> str | None:
match = re.search(r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']", blob)
return match.group(1) if match else None
def extract_client_direct_response_types(meshchat_py: Path) -> list[str]:
text = meshchat_py.read_text(encoding="utf-8")
start = text.find("async def on_websocket_data_received")
if start < 0:
return []
end = text.find("\n async def ", start + 1)
block = text[start:end] if end > start else text[start:]
types: set[str] = set()
for blob in _WS_SEND_STR_RE.findall(block):
msg_type = _extract_type_literals_from_dict_literal(blob)
if msg_type:
types.add(msg_type)
return sorted(types)
def extract_server_broadcast_types(meshchat_py: Path) -> list[str]:
text = meshchat_py.read_text(encoding="utf-8")
types: set[str] = set()
for match in _BROADCAST_CALL_RE.finditer(text):
chunk = text[match.end() : match.end() + 1200]
type_match = _WS_TYPE_LITERAL_RE.search(chunk)
if type_match:
types.add(type_match.group(1))
for fn_name in ("send_config_to_websocket_clients",):
start = text.find(f"async def {fn_name}")
if start < 0:
continue
end = text.find("\n async def ", start + 1)
block = text[start:end] if end > start else text[start : start + 6000]
for type_match in _WS_TYPE_LITERAL_RE.finditer(block):
types.add(type_match.group(1))
return sorted(types)
def load_ws_manifest(fixture_path: Path) -> dict[str, list[str]]:
data = json.loads(fixture_path.read_text(encoding="utf-8"))
for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
data[key] = sorted(data.get(key, []))
return data
def write_ws_manifest(fixture_path: Path, manifest: dict[str, list[str]]) -> None:
fixture_path.parent.mkdir(parents=True, exist_ok=True)
ordered = {
"client_inbound": sorted(manifest.get("client_inbound", [])),
"client_direct_responses": sorted(manifest.get("client_direct_responses", [])),
"server_broadcast": sorted(manifest.get("server_broadcast", [])),
}
fixture_path.write_text(
json.dumps(ordered, indent=2) + "\n",
encoding="utf-8",
)

View file

@ -0,0 +1,271 @@
# SPDX-License-Identifier: 0BSD
"""JSON Schema definitions for WebSocket message bodies (contract tests)."""
from __future__ import annotations
from tests.backend.api_json_contract_schemas import assert_matches_schema
def _ws_type(
msg_type: str,
*,
required: list[str] | None = None,
properties: dict | None = None,
additional_properties: bool = True,
) -> dict:
props: dict = {"type": {"type": "string", "const": msg_type}}
if properties:
props.update(properties)
return {
"type": "object",
"required": ["type", *(required or [])],
"properties": props,
"additionalProperties": additional_properties,
}
_WS_OBJECT = {"type": "object", "additionalProperties": True}
_WS_STRING = {"type": "string"}
_WS_INTEGER = {"type": "integer"}
_WS_BOOL = {"type": "boolean"}
WS_MESSAGE_SCHEMAS: dict[str, dict] = {
"ping": _ws_type("ping"),
"pong": _ws_type("pong"),
"config.set": _ws_type("config.set", required=["config"], properties={"config": _WS_OBJECT}),
"config": _ws_type("config", required=["config"], properties={"config": _WS_OBJECT}),
"announced": _ws_type("announced"),
"blocked_destinations": _ws_type(
"blocked_destinations",
required=["blocked_destinations"],
properties={"blocked_destinations": {"type": "array", "items": _WS_OBJECT}},
),
"keyboard_shortcuts.get": _ws_type("keyboard_shortcuts.get"),
"keyboard_shortcuts.set": _ws_type(
"keyboard_shortcuts.set",
required=["shortcuts"],
properties={"shortcuts": _WS_OBJECT},
),
"keyboard_shortcuts.delete": _ws_type("keyboard_shortcuts.delete", required=["id"]),
"keyboard_shortcuts": _ws_type(
"keyboard_shortcuts",
required=["shortcuts"],
properties={"shortcuts": _WS_OBJECT},
),
"nomadnet.download.cancel": _ws_type(
"nomadnet.download.cancel",
required=["download_id"],
properties={"download_id": _WS_INTEGER},
),
"nomadnet.download.cancelled": _ws_type(
"nomadnet.download.cancelled",
required=["download_id"],
properties={"download_id": _WS_INTEGER},
),
"nomadnet.page.archives.get": _ws_type(
"nomadnet.page.archives.get",
required=["destination_hash", "page_path"],
),
"nomadnet.page.archives": _ws_type(
"nomadnet.page.archives",
required=["destination_hash", "page_path", "archives"],
properties={"archives": {"type": "array", "items": _WS_OBJECT}},
),
"nomadnet.page.archive.load": _ws_type(
"nomadnet.page.archive.load",
required=["archive_id"],
),
"nomadnet.page.archive.flush": _ws_type("nomadnet.page.archive.flush"),
"nomadnet.page.archive.add": _ws_type(
"nomadnet.page.archive.add",
required=["destination_hash", "page_path", "content"],
),
"nomadnet.page.archive.added": _ws_type(
"nomadnet.page.archive.added",
required=["destination_hash", "page_path"],
),
"nomadnet.file.download": _ws_type(
"nomadnet.file.download",
required=["download_id", "nomadnet_file_download"],
properties={"nomadnet_file_download": _WS_OBJECT},
),
"nomadnet.page.download": _ws_type(
"nomadnet.page.download",
required=["download_id", "nomadnet_page_download"],
properties={"nomadnet_page_download": _WS_OBJECT},
),
"lxmf.forwarding.rules.get": _ws_type("lxmf.forwarding.rules.get"),
"lxmf.forwarding.rules": _ws_type(
"lxmf.forwarding.rules",
required=["rules"],
properties={"rules": {"type": "array", "items": _WS_OBJECT}},
),
"lxmf.forwarding.rule.add": _ws_type(
"lxmf.forwarding.rule.add",
required=["rule"],
properties={"rule": _WS_OBJECT},
),
"lxmf.forwarding.rule.delete": _ws_type(
"lxmf.forwarding.rule.delete",
required=["rule_id"],
),
"lxmf.forwarding.rule.toggle": _ws_type(
"lxmf.forwarding.rule.toggle",
required=["rule_id", "enabled"],
properties={"enabled": _WS_BOOL},
),
"lxm.ingest_uri": _ws_type("lxm.ingest_uri", required=["uri"]),
"lxm.ingest_uri.result": _ws_type(
"lxm.ingest_uri.result",
required=["success"],
properties={"success": _WS_BOOL},
),
"lxm.generate_paper_uri": _ws_type("lxm.generate_paper_uri"),
"lxm.generate_paper_uri.result": _ws_type(
"lxm.generate_paper_uri.result",
required=["success"],
properties={"success": _WS_BOOL},
),
"announce": _ws_type("announce", required=["announce"], properties={"announce": _WS_OBJECT}),
"lxmf.delivery": _ws_type("lxmf.delivery"),
"lxmf_message_created": _ws_type(
"lxmf_message_created",
required=["lxmf_message"],
properties={"lxmf_message": _WS_OBJECT},
),
"lxmf_message_state_updated": _ws_type(
"lxmf_message_state_updated",
required=["lxmf_message"],
properties={"lxmf_message": _WS_OBJECT},
),
"lxmf_message_deleted": _ws_type("lxmf_message_deleted"),
"lxmf.telemetry": _ws_type("lxmf.telemetry", required=["destination_hash"]),
"identity_switched": _ws_type("identity_switched"),
"reticulum_reload_status": _ws_type("reticulum_reload_status", required=["status"]),
"new_voicemail": _ws_type("new_voicemail"),
"telephone_ringing": _ws_type("telephone_ringing"),
"telephone_call_established": _ws_type("telephone_call_established"),
"telephone_missed_call": _ws_type("telephone_missed_call"),
"telephone_call_ended": _ws_type("telephone_call_ended"),
"telephone_initiation_status": _ws_type(
"telephone_initiation_status",
required=["status"],
),
"rrc.change": _ws_type("rrc.change"),
"rrc.message": _ws_type("rrc.message", required=["message"], properties={"message": _WS_OBJECT}),
"rrc.server.change": _ws_type("rrc.server.change"),
"rnsh.session.change": _ws_type("rnsh.session.change"),
"rnsh.output": _ws_type("rnsh.output"),
"rncp.transfer.progress": _ws_type("rncp.transfer.progress"),
"rncp.send.completed": _ws_type("rncp.send.completed"),
"rncp.fetch.completed": _ws_type("rncp.fetch.completed"),
}
WS_MESSAGE_SAMPLES: dict[str, dict] = {
"ping": {"type": "ping"},
"pong": {"type": "pong"},
"config.set": {"type": "config.set", "config": {"display_name": "Test"}},
"config": {"type": "config", "config": {"display_name": "Test"}},
"announced": {"type": "announced"},
"blocked_destinations": {"type": "blocked_destinations", "blocked_destinations": []},
"keyboard_shortcuts.get": {"type": "keyboard_shortcuts.get"},
"keyboard_shortcuts.set": {"type": "keyboard_shortcuts.set", "shortcuts": {}},
"keyboard_shortcuts.delete": {"type": "keyboard_shortcuts.delete", "id": "nav_messages"},
"keyboard_shortcuts": {"type": "keyboard_shortcuts", "shortcuts": {}},
"nomadnet.download.cancel": {"type": "nomadnet.download.cancel", "download_id": 1},
"nomadnet.download.cancelled": {"type": "nomadnet.download.cancelled", "download_id": 1},
"nomadnet.page.archives.get": {
"type": "nomadnet.page.archives.get",
"destination_hash": "aa" * 16,
"page_path": "/page/index.mu",
},
"nomadnet.page.archives": {
"type": "nomadnet.page.archives",
"destination_hash": "aa" * 16,
"page_path": "/page/index.mu",
"archives": [],
},
"nomadnet.page.archive.load": {"type": "nomadnet.page.archive.load", "archive_id": 1},
"nomadnet.page.archive.flush": {"type": "nomadnet.page.archive.flush"},
"nomadnet.page.archive.add": {
"type": "nomadnet.page.archive.add",
"destination_hash": "aa" * 16,
"page_path": "/page/index.mu",
"content": "body",
},
"nomadnet.page.archive.added": {
"type": "nomadnet.page.archive.added",
"destination_hash": "aa" * 16,
"page_path": "/page/index.mu",
},
"nomadnet.file.download": {
"type": "nomadnet.file.download",
"download_id": 1,
"nomadnet_file_download": {"status": "success"},
},
"nomadnet.page.download": {
"type": "nomadnet.page.download",
"download_id": 1,
"nomadnet_page_download": {"status": "success"},
},
"lxmf.forwarding.rules.get": {"type": "lxmf.forwarding.rules.get"},
"lxmf.forwarding.rules": {"type": "lxmf.forwarding.rules", "rules": []},
"lxmf.forwarding.rule.add": {"type": "lxmf.forwarding.rule.add", "rule": {"id": 1}},
"lxmf.forwarding.rule.delete": {"type": "lxmf.forwarding.rule.delete", "rule_id": 1},
"lxmf.forwarding.rule.toggle": {
"type": "lxmf.forwarding.rule.toggle",
"rule_id": 1,
"enabled": True,
},
"lxm.ingest_uri": {"type": "lxm.ingest_uri", "uri": "meshchatx://map"},
"lxm.ingest_uri.result": {"type": "lxm.ingest_uri.result", "success": True},
"lxm.generate_paper_uri": {"type": "lxm.generate_paper_uri"},
"lxm.generate_paper_uri.result": {
"type": "lxm.generate_paper_uri.result",
"success": True,
"uri": "lxm://paper",
},
"announce": {"type": "announce", "announce": {"aspect": "lxmf.delivery"}},
"lxmf.delivery": {"type": "lxmf.delivery"},
"lxmf_message_created": {
"type": "lxmf_message_created",
"lxmf_message": {"hash": "cc" * 16},
},
"lxmf_message_state_updated": {
"type": "lxmf_message_state_updated",
"lxmf_message": {"hash": "cc" * 16, "state": "sent"},
},
"lxmf_message_deleted": {"type": "lxmf_message_deleted", "hash": "cc" * 16},
"lxmf.telemetry": {"type": "lxmf.telemetry", "destination_hash": "aa" * 16},
"identity_switched": {"type": "identity_switched"},
"reticulum_reload_status": {"type": "reticulum_reload_status", "status": "ok"},
"new_voicemail": {"type": "new_voicemail", "id": 1},
"telephone_ringing": {"type": "telephone_ringing", "remote_identity_hash": "aa" * 16},
"telephone_call_established": {"type": "telephone_call_established"},
"telephone_missed_call": {"type": "telephone_missed_call"},
"telephone_call_ended": {"type": "telephone_call_ended"},
"telephone_initiation_status": {"type": "telephone_initiation_status", "status": "ringing"},
"rrc.change": {"type": "rrc.change"},
"rrc.message": {"type": "rrc.message", "message": {"kind": "msg"}},
"rrc.server.change": {"type": "rrc.server.change"},
"rnsh.session.change": {"type": "rnsh.session.change"},
"rnsh.output": {"type": "rnsh.output", "data": "ok"},
"rncp.transfer.progress": {"type": "rncp.transfer.progress", "percent": 50},
"rncp.send.completed": {"type": "rncp.send.completed"},
"rncp.fetch.completed": {"type": "rncp.fetch.completed"},
}
def all_ws_message_types() -> set[str]:
return set(WS_MESSAGE_SCHEMAS)
def assert_ws_message_matches_schema(message: dict) -> None:
msg_type = message.get("type")
if not isinstance(msg_type, str):
raise AssertionError("WebSocket message missing string type")
schema = WS_MESSAGE_SCHEMAS.get(msg_type)
if schema is None:
raise AssertionError(f"No WebSocket schema registered for type {msg_type!r}")
assert_matches_schema(message, schema)

View file

@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"<div="" class="fixed inset-0 z-9999 flex items-center justify-center p-4 confirm-dialog-enter-from confirm-dialog-enter-active"><div="" class="fixed inset-0 bg-black/50 backdrop-blur-xs shadow-2xl"></div><div="" class="relative w-full sm:w-auto sm:min-w-[400px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-3xl rounded-3xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"><div="" class="p-8"><div="" class="flex items-start mb-6"><div="" class="shrink-0 flex items-center justify-center w-12 h-12 rounded-2xl bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 mr-4"><span="" class="mdi-snapshot w-6 h-6" data-icon="alert-circle"></span></div><div="" class="flex-1 min-w-0"><h3="" class="text-xl font-black text-gray-900 dark:text-white mb-2">Confirm Action</h3><p="" class="text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">Delete this item?</p></div></div><div="" class="flex flex-col sm:flex-row gap-3 sm:justify-end mt-8"><button="" type="button" class="px-6 py-3 text-sm font-bold text-gray-700 dark:text-zinc-300 bg-gray-100 dark:bg-zinc-800 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-95"> Cancel </button><button="" type="button" class="px-6 py-3 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl shadow-lg shadow-red-600/20 transition-all active:scale-95"> Confirm </button></div></div></div></div>"`;
exports[`UI snapshot regression > FormLabel.vue > default label 1`] = `"<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">Display name</label>"`;
exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"<div class="text-xs text-gray-600 dark:text-zinc-300">Optional helper text</div>"`;
exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" class="hover:bg-gray-100 dark:hover:bg-zinc-700 w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;

View file

@ -0,0 +1,49 @@
import { mount } from "@vue/test-utils";
export const snapshotMaterialDesignIcon = {
name: "MaterialDesignIcon",
template: '<span class="mdi-snapshot" :data-icon="iconName"></span>',
props: ["iconName"],
};
export const snapshotTransitionGroup = {
template: "<div class=\"snapshot-transition-group\"><slot /></div>",
};
export function snapshotI18nMock() {
return (key) => key;
}
export function normalizeSnapshotHtml(html) {
return String(html)
.replace(/\sdata-v-[a-f0-9]+/g, "")
.replace(/\s+id="[^"]*"/g, "")
.replace(/\s+style="[^"]*"/g, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/>\s+</g, "><")
.trim();
}
export function mountSnapshot(component, options = {}) {
const { props, slots, global = {} } = options;
return mount(component, {
props,
slots,
global: {
mocks: {
$t: snapshotI18nMock(),
...(global.mocks || {}),
},
stubs: {
MaterialDesignIcon: snapshotMaterialDesignIcon,
TransitionGroup: snapshotTransitionGroup,
...(global.stubs || {}),
},
...global,
},
});
}
export function expectHtmlSnapshot(wrapper) {
expect(normalizeSnapshotHtml(wrapper.html())).toMatchSnapshot();
}

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import Toast from "@/components/Toast.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import FormLabel from "@/components/forms/FormLabel.vue";
import FormSubLabel from "@/components/forms/FormSubLabel.vue";
import IconButton from "@/components/IconButton.vue";
import SidebarLink from "@/components/SidebarLink.vue";
import GlobalEmitter from "@/js/GlobalEmitter";
import { expectHtmlSnapshot, mountSnapshot } from "./snapshotTestUtils.js";
vi.mock("@/js/GlobalEmitter", () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
const RouterLinkStub = {
name: "RouterLinkStub",
props: ["to"],
template: '<a class="router-link-stub" href="#"><slot :href="\'#\'" :navigate="() => {}" :isActive="false"/></a>',
};
describe("UI snapshot regression", () => {
describe("Toast.vue", () => {
let wrapper;
beforeEach(() => {
vi.useFakeTimers();
wrapper = mountSnapshot(Toast);
});
afterEach(() => {
wrapper?.unmount();
vi.useRealTimers();
});
it("empty state", () => {
expectHtmlSnapshot(wrapper);
});
it("success toast", async () => {
GlobalEmitter.emit("toast", { message: "Saved", type: "success", duration: 0 });
await wrapper.vm.$nextTick();
expectHtmlSnapshot(wrapper);
});
it("error toast", async () => {
GlobalEmitter.emit("toast", { message: "Failed", type: "error", duration: 0 });
await wrapper.vm.$nextTick();
expectHtmlSnapshot(wrapper);
});
it("loading toast", async () => {
GlobalEmitter.emit("toast", {
message: "Working",
type: "loading",
duration: 0,
key: "job-1",
});
await wrapper.vm.$nextTick();
expectHtmlSnapshot(wrapper);
});
});
describe("ConfirmDialog.vue", () => {
it("visible confirm dialog", async () => {
const wrapper = mountSnapshot(ConfirmDialog);
const showFn = vi.mocked(GlobalEmitter.on).mock.calls.find((c) => c[0] === "confirm")?.[1];
showFn?.({ message: "Delete this item?", resolve: vi.fn() });
await wrapper.vm.$nextTick();
expectHtmlSnapshot(wrapper);
wrapper.unmount();
});
});
describe("FormLabel.vue", () => {
it("default label", () => {
const wrapper = mountSnapshot(FormLabel, {
slots: { default: "Display name" },
});
expectHtmlSnapshot(wrapper);
wrapper.unmount();
});
});
describe("FormSubLabel.vue", () => {
it("default sub-label", () => {
const wrapper = mountSnapshot(FormSubLabel, {
slots: { default: "Optional helper text" },
});
expectHtmlSnapshot(wrapper);
wrapper.unmount();
});
});
describe("IconButton.vue", () => {
it("icon button", () => {
const wrapper = mountSnapshot(IconButton, {
slots: { default: '<span class="icon">+</span>' },
});
expectHtmlSnapshot(wrapper);
wrapper.unmount();
});
});
describe("SidebarLink.vue", () => {
it("sidebar link", () => {
const wrapper = mountSnapshot(SidebarLink, {
props: { to: { name: "messages" } },
slots: {
icon: '<span class="icon-slot">M</span>',
text: "Messages",
},
global: { stubs: { RouterLink: RouterLinkStub } },
});
expectHtmlSnapshot(wrapper);
wrapper.unmount();
});
});
});