mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 05:32:22 +00:00
feat(tests): add loopback TCP fixture for integration tests and apply it to relevant test cases
This commit is contained in:
15 files changed
+244
-15
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -20,6 +21,35 @@ from meshchatx.src.backend.database.schema import DatabaseSchema
|
||||
os.environ["MESHCHAT_LOG_DIR"] = tempfile.mkdtemp()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def loopback_available():
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
accepted = None
|
||||
try:
|
||||
server.settimeout(0.5)
|
||||
client.settimeout(0.5)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
port = server.getsockname()[1]
|
||||
client.connect(("127.0.0.1", port))
|
||||
accepted, _ = server.accept()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if accepted is not None:
|
||||
accepted.close()
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def require_loopback_tcp(loopback_available):
|
||||
if not loopback_available:
|
||||
pytest.skip("Loopback TCP is blocked by local firewall/policy; skipping localhost integration test.")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def global_mocks():
|
||||
with (
|
||||
|
||||
@@ -240,6 +240,7 @@ def _make_aio_app(mock_app, use_https: bool):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_login_records_access_attempt_and_debug_list(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
pw = b"smoke-access-attempt-pw-ok"
|
||||
@@ -267,6 +268,7 @@ async def test_login_records_access_attempt_and_debug_list(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_lockout_login_returns_429_smoke(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
pw = b"lockout-smoke-pw"
|
||||
@@ -297,6 +299,7 @@ async def test_lockout_login_returns_429_smoke(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_rate_limited_login_returns_429_smoke(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
pw = b"rl-smoke-pw"
|
||||
@@ -346,6 +349,7 @@ async def test_rate_limited_login_returns_429_smoke(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_debug_access_attempts_endpoint_returns_shape(mock_app):
|
||||
mock_app.config.auth_enabled.set(False)
|
||||
aio_app = _make_aio_app(mock_app, use_https=False)
|
||||
|
||||
@@ -111,7 +111,9 @@ def test_get_status_reads_sidecar_lxmf_address(temp_identity_dir):
|
||||
storage = os.path.join(handler.bots_dir, sid)
|
||||
os.makedirs(storage, exist_ok=True)
|
||||
hx = "a" * 32
|
||||
with open(os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8") as f:
|
||||
with open(
|
||||
os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(hx)
|
||||
handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}]
|
||||
status = handler.get_status()
|
||||
@@ -176,7 +178,9 @@ def test_request_announce_writes_trigger(mock_alive, temp_identity_dir):
|
||||
sid = "b1"
|
||||
storage = os.path.join(handler.bots_dir, sid)
|
||||
os.makedirs(storage, exist_ok=True)
|
||||
handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}]
|
||||
handler.bots_state = [
|
||||
{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}
|
||||
]
|
||||
handler.request_announce(sid)
|
||||
req = os.path.join(storage, "meshchatx_request_announce")
|
||||
assert os.path.isfile(req)
|
||||
@@ -189,6 +193,8 @@ def test_request_announce_not_running(temp_identity_dir):
|
||||
sid = "b1"
|
||||
storage = os.path.join(handler.bots_dir, sid)
|
||||
os.makedirs(storage, exist_ok=True)
|
||||
handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}]
|
||||
handler.bots_state = [
|
||||
{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}
|
||||
]
|
||||
with pytest.raises(RuntimeError, match="not running"):
|
||||
handler.request_announce(sid)
|
||||
@@ -52,6 +52,7 @@ def test_encrypted_cookie_storage_http_flags(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_login_sets_cookie_and_allows_protected_api(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
pw = b"integration-test-password-ok"
|
||||
@@ -79,6 +80,7 @@ async def test_login_sets_cookie_and_allows_protected_api(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_ws_returns_401_without_session_when_auth_enabled(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
mock_app.config.auth_password_hash.set(
|
||||
@@ -92,6 +94,7 @@ async def test_ws_returns_401_without_session_when_auth_enabled(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_logout_clears_session_for_protected_api(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
pw = b"logout-test-password-ok"
|
||||
@@ -111,6 +114,7 @@ async def test_logout_clears_session_for_protected_api(mock_app):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_auth_login_invalid_json_returns_400(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
mock_app.config.auth_password_hash.set(
|
||||
@@ -135,6 +139,7 @@ async def test_auth_login_invalid_json_returns_400(mock_app):
|
||||
deadline=None,
|
||||
)
|
||||
@given(body=st.binary(min_size=0, max_size=12000))
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
def test_auth_login_fuzz_never_500(mock_app, body):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
mock_app.config.auth_password_hash.set(
|
||||
|
||||
@@ -17,6 +17,8 @@ from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
|
||||
|
||||
|
||||
def _make_self_signed_cert_and_key(cert_path: str, key_path: str) -> None:
|
||||
private_key = rsa.generate_private_key(
|
||||
|
||||
@@ -104,12 +104,16 @@ def integration_app(temp_dir):
|
||||
|
||||
|
||||
def _route_handler(app, path, method="GET"):
|
||||
return next(r.handler for r in app.get_routes() if r.path == path and r.method == method)
|
||||
return next(
|
||||
r.handler for r in app.get_routes() if r.path == path and r.method == method
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_remote_propagation_sync_transitions_path_requested_to_complete(integration_app):
|
||||
async def test_remote_propagation_sync_transitions_path_requested_to_complete(
|
||||
integration_app,
|
||||
):
|
||||
app, fake_router = integration_app
|
||||
remote_hash = b"\x22" * 16
|
||||
fake_router.set_outbound_propagation_node(remote_hash)
|
||||
@@ -131,18 +135,24 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in
|
||||
|
||||
with (
|
||||
patch("meshchatx.meshchat.RNS.Transport.has_path", side_effect=has_path),
|
||||
patch("meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path),
|
||||
patch(
|
||||
"meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path
|
||||
),
|
||||
):
|
||||
first_sync = await sync_handler(None)
|
||||
assert first_sync.status == 200
|
||||
|
||||
first_status = json.loads((await status_handler(None)).body)["propagation_node_status"]
|
||||
first_status = json.loads((await status_handler(None)).body)[
|
||||
"propagation_node_status"
|
||||
]
|
||||
assert first_status["state"] == "path_requested"
|
||||
|
||||
second_sync = await sync_handler(None)
|
||||
assert second_sync.status == 200
|
||||
|
||||
second_status = json.loads((await status_handler(None)).body)["propagation_node_status"]
|
||||
second_status = json.loads((await status_handler(None)).body)[
|
||||
"propagation_node_status"
|
||||
]
|
||||
assert second_status["state"] == "complete"
|
||||
assert second_status["progress"] == 100.0
|
||||
assert fake_router.request_messages_calls >= 2
|
||||
@@ -150,7 +160,9 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_local_preferred_propagation_sync_completes_without_remote_lookup(integration_app):
|
||||
async def test_local_preferred_propagation_sync_completes_without_remote_lookup(
|
||||
integration_app,
|
||||
):
|
||||
app, fake_router = integration_app
|
||||
local_hash = fake_router.propagation_destination.hash
|
||||
fake_router.set_outbound_propagation_node(local_hash)
|
||||
@@ -165,7 +177,9 @@ async def test_local_preferred_propagation_sync_completes_without_remote_lookup(
|
||||
response = await sync_handler(None)
|
||||
assert response.status == 200
|
||||
|
||||
status_data = json.loads((await status_handler(None)).body)["propagation_node_status"]
|
||||
status_data = json.loads((await status_handler(None)).body)[
|
||||
"propagation_node_status"
|
||||
]
|
||||
assert status_data["state"] == "complete"
|
||||
assert status_data["progress"] == 100.0
|
||||
assert fake_router.request_messages_calls == 0
|
||||
@@ -445,6 +445,7 @@ def test_random_notification_operations(db, operations):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("require_loopback_tcp")
|
||||
async def test_auth_middleware_returns_401_for_api_without_session_when_auth_enabled(
|
||||
mock_app,
|
||||
):
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("BotsPage.vue", () => {
|
||||
|
||||
const cards = wrapper.findAll("div.cursor-pointer");
|
||||
const templateCard = cards.filter(
|
||||
(d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages"),
|
||||
(d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages")
|
||||
)[0];
|
||||
await templateCard.trigger("click");
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import ConversationViewer from "@/components/messages/ConversationViewer.vue";
|
||||
import WebSocketConnection from "@/js/WebSocketConnection";
|
||||
import GlobalState from "@/js/GlobalState";
|
||||
import DialogUtils from "@/js/DialogUtils";
|
||||
|
||||
vi.mock("@/js/DialogUtils", () => ({
|
||||
default: {
|
||||
@@ -17,6 +18,7 @@ describe("ConversationViewer.vue", () => {
|
||||
GlobalState.config.theme = "light";
|
||||
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
|
||||
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
|
||||
GlobalState.config.warn_on_stranger_links = true;
|
||||
WebSocketConnection.connect();
|
||||
axiosMock = {
|
||||
get: vi.fn().mockImplementation((url) => {
|
||||
@@ -39,6 +41,7 @@ describe("ConversationViewer.vue", () => {
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
window.URL.createObjectURL = vi.fn(() => "mock-url");
|
||||
vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
// Mock FileReader
|
||||
const mockFileReader = {
|
||||
@@ -57,6 +60,9 @@ describe("ConversationViewer.vue", () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete window.api;
|
||||
if (window.open?.mockRestore) {
|
||||
window.open.mockRestore();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
WebSocketConnection.destroy();
|
||||
});
|
||||
@@ -173,6 +179,80 @@ describe("ConversationViewer.vue", () => {
|
||||
expect(wrapper.vm.composerImageDropActive).toBe(false);
|
||||
});
|
||||
|
||||
it("warns before opening http links from strangers when enabled", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.isStrangerPeer = true;
|
||||
GlobalState.config.warn_on_stranger_links = true;
|
||||
DialogUtils.confirm.mockClear();
|
||||
window.open.mockClear();
|
||||
DialogUtils.confirm.mockResolvedValueOnce(true);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", "https://example.com/path");
|
||||
const event = { target: anchor, preventDefault: vi.fn() };
|
||||
|
||||
await wrapper.vm.handleMessageClick(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm");
|
||||
expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("does not open stranger http link when warning confirm is rejected", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.isStrangerPeer = true;
|
||||
GlobalState.config.warn_on_stranger_links = true;
|
||||
DialogUtils.confirm.mockClear();
|
||||
window.open.mockClear();
|
||||
DialogUtils.confirm.mockResolvedValueOnce(false);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", "https://example.com/path");
|
||||
const event = { target: anchor, preventDefault: vi.fn() };
|
||||
|
||||
await wrapper.vm.handleMessageClick(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(DialogUtils.confirm).toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens stranger http link without prompt when warning is disabled", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.isStrangerPeer = true;
|
||||
GlobalState.config.warn_on_stranger_links = false;
|
||||
DialogUtils.confirm.mockClear();
|
||||
window.open.mockClear();
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", "https://example.com/path");
|
||||
const event = { target: anchor, preventDefault: vi.fn() };
|
||||
|
||||
await wrapper.vm.handleMessageClick(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(DialogUtils.confirm).not.toHaveBeenCalled();
|
||||
expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
|
||||
});
|
||||
|
||||
it("blocks non-http href payloads like data urls in message anchors", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.isStrangerPeer = true;
|
||||
GlobalState.config.warn_on_stranger_links = true;
|
||||
DialogUtils.confirm.mockClear();
|
||||
window.open.mockClear();
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", "data:image/png;base64,AAAA");
|
||||
const event = { target: anchor, preventDefault: vi.fn() };
|
||||
|
||||
await wrapper.vm.handleMessageClick(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(DialogUtils.confirm).not.toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onComposerImageDrop ignores non-image files", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const pdf = new File([""], "doc.pdf", { type: "application/pdf" });
|
||||
|
||||
@@ -126,6 +126,13 @@ describe("LinkUtils.js", () => {
|
||||
expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i);
|
||||
});
|
||||
|
||||
it("does not produce data: href for data image payload text", () => {
|
||||
const text = "inline image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA";
|
||||
const result = LinkUtils.renderStandardLinks(text);
|
||||
expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i);
|
||||
expect(result).not.toContain("<a ");
|
||||
});
|
||||
|
||||
it("stops URL at space so no script in same line", () => {
|
||||
const text = "https://example.com javascript:alert(1)";
|
||||
const result = LinkUtils.renderStandardLinks(text);
|
||||
|
||||
@@ -49,8 +49,7 @@ describe("MarkdownRenderer.js", () => {
|
||||
});
|
||||
|
||||
it("keeps underscores intact in long https links", () => {
|
||||
const url =
|
||||
"https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
|
||||
const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
|
||||
const result = MarkdownRenderer.render(`visit ${url}`);
|
||||
expect(result).toContain(`href="${url}"`);
|
||||
expect(result).toContain(url);
|
||||
@@ -390,8 +389,7 @@ describe("MarkdownRenderer.js", () => {
|
||||
});
|
||||
|
||||
it("strip keeps intraword underscores intact", () => {
|
||||
const url =
|
||||
"https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
|
||||
const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
|
||||
expect(MarkdownRenderer.strip(url)).toBe(url);
|
||||
});
|
||||
|
||||
|
||||
@@ -171,6 +171,16 @@ describe("MessagesSidebar UI", () => {
|
||||
expect(conversationsPanel.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses right-edge collapse icons when sidebar position is right", () => {
|
||||
const left = mountSidebar({ sidebarPosition: "left" });
|
||||
expect(left.vm.expandedTabBarChevronIcon).toBe("chevron-left");
|
||||
expect(left.vm.collapsedStripChevronIcon).toBe("chevron-right");
|
||||
|
||||
const right = mountSidebar({ sidebarPosition: "right" });
|
||||
expect(right.vm.expandedTabBarChevronIcon).toBe("chevron-right");
|
||||
expect(right.vm.collapsedStripChevronIcon).toBe("chevron-left");
|
||||
});
|
||||
|
||||
it("emits conversation-click when a conversation row is clicked", async () => {
|
||||
const conversations = [
|
||||
{
|
||||
|
||||
@@ -137,6 +137,13 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_glass_enabled: false });
|
||||
});
|
||||
|
||||
it("onMessagesSidebarPositionChange PATCHes messages_sidebar_position", async () => {
|
||||
const w = await mountSettingsPage(api);
|
||||
w.vm.config.messages_sidebar_position = "right";
|
||||
await w.vm.onMessagesSidebarPositionChange();
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { messages_sidebar_position: "right" });
|
||||
});
|
||||
|
||||
it("resetAppearanceDefaults PATCHes full appearance payload", async () => {
|
||||
const w = await mountSettingsPage(api);
|
||||
await w.vm.resetAppearanceDefaults();
|
||||
@@ -144,6 +151,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
|
||||
"/api/v1/config",
|
||||
expect.objectContaining({
|
||||
theme: "light",
|
||||
messages_sidebar_position: "left",
|
||||
message_font_size: 14,
|
||||
message_icon_size: 28,
|
||||
ui_transparency: 0,
|
||||
@@ -347,6 +355,8 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_all_from_strangers: true });
|
||||
await w.vm.onShowUnknownContactBannerChange(false);
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { show_unknown_contact_banner: false });
|
||||
await w.vm.onWarnOnStrangerLinksChange(false);
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { warn_on_stranger_links: false });
|
||||
});
|
||||
|
||||
it("banishment PATCHes toggle and debounced text/color", async () => {
|
||||
|
||||
@@ -75,10 +75,12 @@ export function buildFullServerConfig(overrides = {}) {
|
||||
block_attachments_from_strangers: true,
|
||||
block_all_from_strangers: false,
|
||||
show_unknown_contact_banner: true,
|
||||
warn_on_stranger_links: true,
|
||||
banished_effect_enabled: true,
|
||||
banished_text: "BANISHED",
|
||||
banished_color: "#dc2626",
|
||||
message_font_size: 14,
|
||||
messages_sidebar_position: "left",
|
||||
message_icon_size: 28,
|
||||
ui_transparency: 0,
|
||||
ui_glass_enabled: true,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { classifyConnectionIssue, classifyFetchError } = require("../../electron/loadingStatusNotice.js");
|
||||
|
||||
describe("loadingStatusNotice", () => {
|
||||
it("classifies backend exits before connection succeeds", () => {
|
||||
const issue = classifyConnectionIssue([], {
|
||||
running: false,
|
||||
lastExitCode: 1,
|
||||
});
|
||||
expect(issue.reason).toBe("backend-exited");
|
||||
expect(issue.headline).toContain("stopped");
|
||||
});
|
||||
|
||||
it("classifies address-unreachable network errors as loopback blocks", () => {
|
||||
const issue = classifyConnectionIssue([{ kind: "address-unreachable" }, { kind: "network-error" }]);
|
||||
expect(issue.reason).toBe("loopback-blocked");
|
||||
expect(issue.detail).toContain("firewall");
|
||||
});
|
||||
|
||||
it("classifies HTTP 5xx failures as backend-side startup errors", () => {
|
||||
const issue = classifyConnectionIssue([{ kind: "http-error", status: 503 }]);
|
||||
expect(issue.reason).toBe("backend-http-error");
|
||||
expect(issue.headline).toContain("internal error");
|
||||
});
|
||||
|
||||
it("delays generic network-blocked warnings while backend may still be starting", () => {
|
||||
const issue = classifyConnectionIssue([{ kind: "network-error" }], null, {
|
||||
attemptCount: 10,
|
||||
networkWarnAfterAttempts: 24,
|
||||
});
|
||||
expect(issue.reason).toBe("starting");
|
||||
});
|
||||
|
||||
it("shows generic network-blocked warning after startup grace period", () => {
|
||||
const issue = classifyConnectionIssue([{ kind: "network-error" }], null, {
|
||||
attemptCount: 30,
|
||||
networkWarnAfterAttempts: 24,
|
||||
});
|
||||
expect(issue.reason).toBe("network-blocked");
|
||||
expect(issue.detail).toContain("firewall");
|
||||
});
|
||||
|
||||
it("classifies invalid payload responses as backend response issues", () => {
|
||||
const issue = classifyConnectionIssue([{ kind: "invalid-payload" }]);
|
||||
expect(issue.reason).toBe("backend-invalid-response");
|
||||
});
|
||||
|
||||
it("parses ERR_ADDRESS_UNREACHABLE fetch errors", () => {
|
||||
const kind = classifyFetchError(new TypeError("net::ERR_ADDRESS_UNREACHABLE"));
|
||||
expect(kind).toBe("address-unreachable");
|
||||
});
|
||||
|
||||
it("treats other fetch failures as generic network errors", () => {
|
||||
const kind = classifyFetchError(new TypeError("Failed to fetch"));
|
||||
expect(kind).toBe("network-error");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user