feat: omarchy-beeper bar widget for unread Beeper chats

This commit is contained in:
prdlk committed 2026-09-01 11:15:22 -04:00
commit 7fc78f1075
17 files changed
+3483

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
"""Shared helpers for mocked contract tests. Never talks to Beeper."""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
from contextlib import contextmanager, redirect_stdout
from io import BytesIO, StringIO
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
if str(LIB) not in sys.path:
sys.path.insert(0, str(LIB))
def capture_json(fn, *args, **kwargs) -> dict:
buf = StringIO()
with redirect_stdout(buf):
try:
fn(*args, **kwargs)
except SystemExit:
pass
text = buf.getvalue().strip()
if not text:
raise AssertionError("expected JSON on stdout")
return json.loads(text.splitlines()[-1])
class FakeResponse:
"""Enough of an http.client.HTTPResponse for read_http_body()."""
def __init__(self, payload: object, status: int = 200, content_length: object | None = None):
if isinstance(payload, (bytes, bytearray)):
raw = bytes(payload)
else:
raw = json.dumps(payload).encode("utf-8")
self._buf = BytesIO(raw)
self.status = status
declared = len(raw) if content_length is None else content_length
self.headers = {} if declared is False else {"Content-Length": str(declared)}
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
def __enter__(self) -> FakeResponse:
return self
def __exit__(self, *exc: object) -> bool:
return False
class Call:
def __init__(self, method: str, url: str, body: object) -> None:
parsed = urllib.parse.urlsplit(url)
self.method = method
self.url = url
self.host = parsed.netloc
self.path = parsed.path
self.query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
self.body = body
def one(self, key: str, default: str = "") -> str:
values = self.query.get(key) or []
return values[0] if values else default
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Call {self.method} {self.path} {self.query}>"
class FakeHttp:
"""Route (METHOD, path) to a callable or a fixed response."""
def __init__(self, routes: dict[tuple[str, str], object] | None = None) -> None:
self.routes = dict(routes or {})
self.calls: list[Call] = []
def route(self, method: str, path: str, handler: object) -> None:
self.routes[(method, path)] = handler
def open(self, req, timeout: int | None = None): # noqa: D102 - urlopen shape
body = None
if req.data:
body = json.loads(req.data.decode("utf-8"))
call = Call(req.get_method(), req.full_url, body)
self.calls.append(call)
handler = self.routes.get((call.method, call.path))
if handler is None:
raise urllib.error.HTTPError(call.url, 404, "Not Found", {}, BytesIO(b"{}"))
result = handler(call) if callable(handler) else handler
if isinstance(result, Exception):
raise result
if isinstance(result, FakeResponse):
return result
return FakeResponse(result)
def paths(self, method: str = "") -> list[str]:
return [c.path for c in self.calls if not method or c.method == method]
@contextmanager
def fake_api(http: FakeHttp):
import beeper
with patch.object(beeper, "_OPENER", http):
yield http
def http_error(status: int, message: str = "nope", url: str = "http://localhost:23373/v1/x"):
body = json.dumps({"message": message, "code": "error"}).encode("utf-8")
return urllib.error.HTTPError(url, status, message, {"Content-Length": str(len(body))}, BytesIO(body))
def url_error(reason: Exception | None = None):
return urllib.error.URLError(reason or ConnectionRefusedError(111, "Connection refused"))
def chat(
chat_id: str,
*,
title: str = "Ada Lovelace",
network: str = "WhatsApp",
account: str = "local-whatsapp_1",
last_activity: str = "2026-02-11T10:00:00.000Z",
unread: int = 2,
) -> dict:
return {
"id": chat_id,
"localChatID": "1",
"accountID": account,
"network": network,
"title": title,
"type": "single",
"participants": {"items": [], "hasMore": False, "total": 1},
"lastActivity": last_activity,
"unreadCount": unread,
"isArchived": False,
"isMuted": False,
"isPinned": False,
}
def message(
chat_id: str,
*,
text: str = "are you coming tonight?",
sender: str = "Ada",
timestamp: str = "2026-02-11T10:00:00.000Z",
kind: str = "TEXT",
sort_key: str = "100",
) -> dict:
return {
"id": "m1",
"chatID": chat_id,
"accountID": "local-whatsapp_1",
"senderID": "@ada:example",
"senderName": sender,
"timestamp": timestamp,
"sortKey": sort_key,
"type": kind,
"text": text,
}
def account(account_id: str, network: str) -> dict:
return {
"accountID": account_id,
"loginID": account_id,
"bridge": {"id": network.lower(), "type": network.lower(), "provider": "local"},
"network": network,
"user": {"id": "@me:example", "isSelf": True},
"status": "connected",
}
def search_page(chats: list[dict], has_more: bool = False, cursor: str | None = None) -> dict:
return {
"items": chats,
"hasMore": has_more,
"oldestCursor": cursor,
"newestCursor": None,
}
+493
View File
@@ -0,0 +1,493 @@
from __future__ import annotations
import re
import unittest
from contextlib import redirect_stderr
from io import StringIO
from unittest.mock import patch
from support import ( # noqa: I001 - puts lib/ on sys.path
FakeHttp,
FakeResponse,
account,
capture_json,
chat,
fake_api,
http_error,
message,
search_page,
url_error,
)
import beeper
import cli
import common
TOKEN = "test-token"
SEARCH = "/v1/chats/search"
ACCOUNTS = "/v1/accounts"
FOCUS = "/v1/focus"
def messages_path(chat_id: str) -> str:
import urllib.parse
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages"
def read_path(chat_id: str) -> str:
import urllib.parse
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read"
def wire(http: FakeHttp, chats: list[dict], accounts: list[dict] | None = None) -> FakeHttp:
"""Standard happy-path routing: one search page, snippets, marks."""
http.route("GET", SEARCH, lambda call: search_page(chats))
http.route("GET", ACCOUNTS, accounts if accounts is not None else [account("a1", "WhatsApp")])
http.route("POST", FOCUS, {"success": True})
for item in chats:
chat_id = item["id"]
http.route("GET", messages_path(chat_id), {"items": [message(chat_id)], "hasMore": False})
http.route("POST", read_path(chat_id), item)
return http
def run_list(http: FakeHttp, page: str = "", limit: int = 25) -> dict:
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN), patch.dict(
"os.environ", {"OMARCHY_BEEPER_MAX": str(limit)}, clear=False
):
return capture_json(cli.cmd_list, page)
class UrlBindingTests(unittest.TestCase):
def test_every_call_stays_on_the_local_api(self) -> None:
self.assertTrue(beeper._url("/v1/accounts").startswith("http://localhost:23373/"))
def test_refuses_paths_outside_v1(self) -> None:
for bad in ("/oauth/token", "v1/accounts", "http://evil.test/v1/x", "//evil.test/v1/x"):
with self.assertRaises(beeper.ApiError, msg=bad):
beeper._url(bad)
def test_booleans_and_lists_become_query_values(self) -> None:
url = beeper._url("/v1/chats/search", {"unreadOnly": True, "includeMuted": False})
self.assertIn("unreadOnly=true", url)
self.assertIn("includeMuted=false", url)
def test_redirects_are_not_followed(self) -> None:
self.assertIsNone(
beeper._NoRedirect().redirect_request(None, None, 302, "Found", {}, "http://evil.test/")
)
class ListContractTests(unittest.TestCase):
def test_filters_out_muted_archived_and_low_priority(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
run_list(http)
search = next(c for c in http.calls if c.path == SEARCH)
self.assertEqual(search.one("unreadOnly"), "true")
self.assertEqual(search.one("includeMuted"), "false")
self.assertEqual(search.one("inbox"), "primary")
def test_one_row_per_chat_newest_first(self) -> None:
chats = [
chat("!old:x", title="Old", last_activity="2026-02-01T00:00:00Z"),
chat("!new:x", title="New", last_activity="2026-02-11T00:00:00Z"),
chat("!mid:x", title="Mid", last_activity="2026-02-05T00:00:00Z"),
]
payload = run_list(wire(FakeHttp(), chats))
self.assertEqual([row["subject"] for row in payload["messages"]], ["New", "Mid", "Old"])
self.assertEqual(payload["unread"], 3)
def test_row_shape_matches_the_panel_contract(self) -> None:
chats = [chat("!a:x", title="Ada Lovelace\n", network="Signal")]
payload = run_list(wire(FakeHttp(), chats))
row = payload["messages"][0]
self.assertEqual(
sorted(row),
["from", "id", "labels", "snippet", "subject", "threadId", "ts", "url"],
)
self.assertEqual(row["subject"], "Ada Lovelace")
self.assertEqual(row["threadId"], "!a:x")
self.assertEqual(row["id"], common.encode_id("!a:x"))
self.assertEqual(common.decode_id(row["id"]), "!a:x")
self.assertEqual(row["labels"], ["Signal"])
self.assertEqual(row["from"], "Ada")
self.assertEqual(row["snippet"], "are you coming tonight?")
self.assertEqual(row["url"], "")
self.assertEqual(row["ts"], 1770804000) # 2026-02-11T10:00:00Z
def test_unread_is_the_pile_not_the_page(self) -> None:
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
payload = run_list(wire(FakeHttp(), chats), limit=25)
self.assertEqual(payload["unread"], 40)
self.assertEqual(len(payload["messages"]), 25)
self.assertEqual(payload["thisPage"], "0")
self.assertEqual(payload["nextPage"], "25")
def test_second_page_is_the_offset_slice(self) -> None:
# Descending activity, so c0 is newest and the offset slice starts at c25.
chats = [
chat(f"!c{i}:x", title=f"c{i}", last_activity=f"2026-02-11T10:{59 - i:02d}:00Z")
for i in range(40)
]
payload = run_list(wire(FakeHttp(), chats), page="25", limit=25)
self.assertEqual(payload["thisPage"], "25")
self.assertEqual(len(payload["messages"]), 15)
self.assertEqual(payload["messages"][0]["subject"], "c25")
self.assertEqual(payload["nextPage"], "")
def test_bad_page_token_falls_back_to_the_first_page(self) -> None:
payload = run_list(wire(FakeHttp(), [chat("!a:x")]), page="../etc/passwd")
self.assertEqual(payload["thisPage"], "0")
self.assertEqual(len(payload["messages"]), 1)
def test_snippets_are_only_fetched_for_the_visible_page(self) -> None:
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
http = wire(FakeHttp(), chats)
run_list(http, limit=5)
message_calls = [p for p in http.paths("GET") if p.endswith("/messages")]
self.assertEqual(len(message_calls), 5)
def test_a_failed_snippet_only_loses_the_snippet(self) -> None:
chats = [chat("!a:x")]
http = wire(FakeHttp(), chats)
http.route("GET", messages_path("!a:x"), lambda call: http_error(500, "boom"))
payload = run_list(http)
self.assertTrue(payload["ok"])
self.assertEqual(payload["messages"][0]["snippet"], "")
self.assertEqual(payload["messages"][0]["subject"], "Ada Lovelace")
self.assertEqual(payload["messages"][0]["ts"], 1770804000)
def test_media_messages_get_a_placeholder_snippet(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
http.route(
"GET",
messages_path("!a:x"),
{"items": [message("!a:x", text="", kind="IMAGE")], "hasMore": False},
)
payload = run_list(http)
self.assertEqual(payload["messages"][0]["snippet"], "[photo]")
def test_newest_undeleted_message_wins(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
items = [
message("!a:x", text="older", timestamp="2026-02-11T09:00:00Z", sort_key="1"),
message("!a:x", text="newest", timestamp="2026-02-11T11:00:00Z", sort_key="3"),
dict(
message("!a:x", text="deleted", timestamp="2026-02-11T12:00:00Z", sort_key="4"),
isDeleted=True,
),
dict(message("!a:x", text="👍", timestamp="2026-02-11T13:00:00Z", sort_key="5"), type="REACTION"),
]
http.route("GET", messages_path("!a:x"), {"items": items, "hasMore": False})
payload = run_list(http)
self.assertEqual(payload["messages"][0]["snippet"], "newest")
def test_inboxes_and_account_count_come_from_accounts(self) -> None:
chats = [
chat("!a:x", account="wa", network="WhatsApp"),
chat("!b:x", account="wa", network="WhatsApp"),
chat("!c:x", account="slack", network="Slack"),
]
accounts = [account("wa", "WhatsApp"), account("slack", "Slack"), account("sig", "Signal")]
payload = run_list(wire(FakeHttp(), chats, accounts))
self.assertEqual(payload["accountCount"], 3)
self.assertEqual(
payload["inboxes"],
[
{"account": "WhatsApp", "unread": 2, "searchUrl": ""},
{"account": "Slack", "unread": 1, "searchUrl": ""},
{"account": "Signal", "unread": 0, "searchUrl": ""},
],
)
def test_chats_on_unknown_accounts_are_still_counted(self) -> None:
chats = [chat("!a:x", account="ghost", network="Ghost")]
payload = run_list(wire(FakeHttp(), chats, [account("wa", "WhatsApp")]))
self.assertEqual(payload["inboxes"][-1], {"account": "Other", "unread": 1, "searchUrl": ""})
def test_caught_up_is_a_success_with_an_empty_pile(self) -> None:
payload = run_list(wire(FakeHttp(), []))
self.assertTrue(payload["ok"])
self.assertEqual(payload["unread"], 0)
self.assertEqual(payload["messages"], [])
self.assertEqual(payload["nextPage"], "")
class PaginationWalkTests(unittest.TestCase):
def test_walks_cursors_until_the_api_runs_out(self) -> None:
pages = [
search_page([chat(f"!p1-{i}:x") for i in range(3)], has_more=True, cursor="cur1"),
search_page([chat(f"!p2-{i}:x") for i in range(2)], has_more=False, cursor=None),
]
seen: list[str] = []
def handler(call):
seen.append(call.one("cursor"))
return pages[len(seen) - 1]
http = FakeHttp({("GET", SEARCH): handler})
with fake_api(http):
chats, truncated = beeper.unread_chats(TOKEN)
self.assertEqual(seen, ["", "cur1"])
self.assertEqual(len(chats), 5)
self.assertFalse(truncated)
def test_walk_stops_at_the_fetch_cap_and_reports_truncation(self) -> None:
def handler(call):
return search_page(
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(150)],
has_more=True,
cursor="next",
)
http = FakeHttp({("GET", SEARCH): handler})
with fake_api(http):
chats, truncated = beeper.unread_chats(TOKEN, cap=common.FETCH_CAP)
self.assertEqual(len(chats), common.FETCH_CAP)
self.assertTrue(truncated)
def test_truncated_walk_warns_in_the_list_payload(self) -> None:
def handler(call):
return search_page(
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(200)],
has_more=True,
cursor="next",
)
http = wire(FakeHttp(), [])
http.route("GET", SEARCH, handler)
for i in range(200):
cid = f"!c0-{i}:x"
http.route("GET", messages_path(cid), {"items": [message(cid)], "hasMore": False})
payload = run_list(http, limit=2)
self.assertEqual(payload["unread"], common.FETCH_CAP)
self.assertIn(str(common.FETCH_CAP), payload["warning"])
def test_duplicate_chats_across_pages_are_collapsed(self) -> None:
pages = [
search_page([chat("!dup:x"), chat("!a:x")], has_more=True, cursor="cur1"),
search_page([chat("!dup:x"), chat("!b:x")], has_more=False, cursor=None),
]
state = {"n": 0}
def handler(call):
page = pages[state["n"]]
state["n"] += 1
return page
http = FakeHttp({("GET", SEARCH): handler})
with fake_api(http):
chats, _ = beeper.unread_chats(TOKEN)
self.assertEqual(len(chats), 3)
class ReadTests(unittest.TestCase):
def test_read_marks_exactly_that_chat(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
opaque = common.encode_id("!a:x")
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_read, opaque)
self.assertEqual(payload, {"ok": True})
self.assertEqual(http.paths("POST"), [read_path("!a:x")])
def test_read_refuses_a_forged_id(self) -> None:
http = FakeHttp()
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_read, "beeper:!!!")
self.assertFalse(payload["ok"])
self.assertEqual(http.calls, [])
def test_read_reports_an_api_failure(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
http.route("POST", read_path("!a:x"), lambda call: http_error(500, "bridge offline"))
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_read, common.encode_id("!a:x"))
self.assertFalse(payload["ok"])
self.assertIn("500", payload["error"])
class ReadAllTests(unittest.TestCase):
def _chats(self, n: int) -> list[dict]:
return [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i % 60:02d}:00Z") for i in range(n)]
def _run(self, http: FakeHttp) -> dict:
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
return capture_json(cli.cmd_read_all)
def test_snapshots_before_marking(self) -> None:
chats = self._chats(3)
http = wire(FakeHttp(), chats)
payload = self._run(http)
self.assertEqual(payload, {"ok": True, "marked": 3})
methods = [c.method for c in http.calls]
self.assertEqual(methods[0], "GET")
self.assertEqual(http.calls[0].path, SEARCH)
self.assertNotIn("GET", methods[1:])
def test_walks_past_the_display_cap(self) -> None:
chats = self._chats(300)
http = wire(FakeHttp(), chats)
payload = self._run(http)
self.assertEqual(payload, {"ok": True, "marked": 300})
self.assertEqual(len([c for c in http.calls if c.method == "POST"]), 300)
def test_partial_failure_keeps_the_marked_count(self) -> None:
chats = self._chats(4)
http = wire(FakeHttp(), chats)
http.route("POST", read_path("!c2:x"), lambda call: http_error(500, "bridge offline"))
payload = self._run(http)
self.assertFalse(payload["ok"])
self.assertEqual(payload["marked"], 3)
self.assertIn("500", payload["error"])
def test_budget_stops_the_run_with_a_partial_count(self) -> None:
chats = self._chats(5)
http = wire(FakeHttp(), chats)
with patch.object(cli, "READ_ALL_BUDGET", 0.0), patch.object(cli, "READ_ALL_WORKERS", 1):
payload = self._run(http)
self.assertFalse(payload["ok"])
self.assertEqual(payload["marked"], 0)
self.assertIn("stopped after", payload["error"])
def test_nothing_unread_is_a_no_op(self) -> None:
payload = self._run(wire(FakeHttp(), []))
self.assertEqual(payload, {"ok": True, "marked": 0})
def test_a_failed_snapshot_does_not_mark_anything(self) -> None:
http = FakeHttp({("GET", SEARCH): lambda call: http_error(500, "index rebuilding")})
payload = self._run(http)
self.assertFalse(payload["ok"])
self.assertEqual(http.paths("POST"), [])
class OpenTests(unittest.TestCase):
def test_open_focuses_the_chat(self) -> None:
http = wire(FakeHttp(), [chat("!a:x")])
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_open, common.encode_id("!a:x"))
self.assertEqual(payload, {"ok": True})
focus = next(c for c in http.calls if c.path == FOCUS)
self.assertEqual(focus.body, {"chatID": "!a:x"})
def test_open_without_an_id_only_focuses_the_app(self) -> None:
http = wire(FakeHttp(), [])
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_open, "")
self.assertEqual(payload, {"ok": True})
focus = next(c for c in http.calls if c.path == FOCUS)
self.assertEqual(focus.body, {})
def test_the_only_writes_are_mark_read_and_focus(self) -> None:
source = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
mutations = re.findall(
r'"(POST|PUT|PATCH|DELETE)",\s*(?:#[^\n]*\n\s*)?(f?"[^"]+")', source
)
self.assertTrue(mutations, "expected to find the mutating calls")
for method, path in mutations:
self.assertTrue(
path.endswith('/read"') or path == '"/v1/focus"',
msg=f"unexpected write: {method} {path}",
)
class FailureMappingTests(unittest.TestCase):
def test_401_asks_for_auth(self) -> None:
http = FakeHttp({("GET", SEARCH): lambda call: http_error(401, "Unauthorized")})
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
def test_connection_refused_says_beeper_is_not_running(self) -> None:
http = FakeHttp({("GET", SEARCH): lambda call: url_error()})
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertEqual(payload["error"], "Beeper Desktop is not running")
def test_missing_token_is_told_apart_from_a_dead_app(self) -> None:
http = FakeHttp({("GET", "/v1/info"): {"app": {"name": "Beeper"}}})
with fake_api(http), patch.object(cli, "load_token", return_value=""):
payload = capture_json(cli.cmd_list, "")
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
http = FakeHttp({("GET", "/v1/info"): lambda call: url_error()})
with fake_api(http), patch.object(cli, "load_token", return_value=""):
payload = capture_json(cli.cmd_list, "")
self.assertEqual(payload["error"], "Beeper Desktop is not running")
def test_oversized_response_is_refused_before_decode(self) -> None:
huge = b'{"items":[' + b" " * (common.MAX_HTTP_BODY + 10) + b"]}"
http = FakeHttp({("GET", SEARCH): FakeResponse(huge, content_length=False)})
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertFalse(payload["ok"])
self.assertIn("too large", payload["error"])
def test_declared_oversized_response_is_refused_without_reading(self) -> None:
http = FakeHttp(
{("GET", SEARCH): FakeResponse({"items": []}, content_length=common.MAX_HTTP_BODY + 1)}
)
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertFalse(payload["ok"])
def test_error_bodies_are_capped(self) -> None:
self.assertEqual(beeper.MAX_HTTP_ERROR, 64 * 1024)
def test_invalid_json_is_reported_not_raised(self) -> None:
http = FakeHttp({("GET", SEARCH): FakeResponse(b"not json")})
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertFalse(payload["ok"])
self.assertIn("invalid JSON", payload["error"])
def test_api_error_text_is_one_line(self) -> None:
http = FakeHttp({("GET", SEARCH): lambda call: http_error(503, "a\nb\n c")})
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.cmd_list, "")
self.assertNotIn("\n", payload["error"])
self.assertIn("a b c", payload["error"])
class CliSurfaceTests(unittest.TestCase):
def test_unknown_command_still_prints_one_json_object(self) -> None:
payload = capture_json(cli.main, ["frobnicate"])
self.assertFalse(payload["ok"])
self.assertIn("unknown command", payload["error"])
def test_read_without_an_id_is_a_usage_error(self) -> None:
payload = capture_json(cli.main, ["read"])
self.assertFalse(payload["ok"])
def test_limit_flag_sets_the_page_size(self) -> None:
http = wire(FakeHttp(), [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(10)])
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
payload = capture_json(cli.main, ["list", "--limit", "3"])
self.assertEqual(len(payload["messages"]), 3)
def test_auth_refuses_a_non_terminal(self) -> None:
stderr = StringIO()
with patch("sys.stdin.isatty", return_value=False), redirect_stderr(stderr):
with self.assertRaises(SystemExit) as raised:
cli.cmd_auth()
self.assertEqual(raised.exception.code, 1)
self.assertIn("needs a terminal", stderr.getvalue())
def test_the_token_never_reaches_argv_or_the_shell(self) -> None:
bin_script = (common.ROOT / "bin" / "omarchy-beeper").read_text(encoding="utf-8")
for banned in ("--token", "$TOKEN", "token.json", "secrets"):
self.assertNotIn(banned, bin_script)
cli_source = (common.ROOT / "lib" / "cli.py").read_text(encoding="utf-8")
# Read from a hidden prompt, and only ever handed to the API client.
self.assertIn("getpass.getpass", cli_source)
self.assertNotIn("input(", cli_source)
panel = (common.ROOT / "Panel.qml").read_text(encoding="utf-8")
for banned in ("Authorization", "token.json", "secrets", "XMLHttpRequest", "WebSocket"):
self.assertNotIn(banned, panel)
api = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
self.assertEqual(api.count('add_header("Authorization"'), 1)
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import json
import os
import shutil
import stat
import tempfile
import unittest
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from support import ROOT, capture_json # noqa: I001 - puts lib/ on sys.path
import common
def _workdir() -> Path:
# Outside the plugin folder: `omarchy plugin validate` rejects symlinks
# anywhere inside it, and one of these tests plants one on purpose.
return Path(tempfile.mkdtemp(prefix="omarchy-beeper-tests."))
class HelperTests(unittest.TestCase):
def test_clamp_int(self) -> None:
self.assertEqual(common.clamp_int("25", 10, 1, 50), 25)
self.assertEqual(common.clamp_int("0", 10, 1, 50), 1)
self.assertEqual(common.clamp_int("99", 10, 1, 50), 50)
self.assertEqual(common.clamp_int("nope", 10, 1, 50), 10)
self.assertEqual(common.clamp_int(None, 7, 1, 50), 7)
def test_max_chats_clamps_to_page_size(self) -> None:
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "500"}, clear=False):
self.assertEqual(common.max_chats(), common.PAGE_SIZE_MAX)
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "0"}, clear=False):
self.assertEqual(common.max_chats(), 1)
def test_fetch_limit_caps_at_200(self) -> None:
self.assertEqual(common.fetch_limit("200"), 200)
self.assertEqual(common.fetch_limit("999"), 200)
self.assertEqual(common.fetch_limit("0"), 1)
def test_one_line_collapses_and_truncates(self) -> None:
self.assertEqual(common.one_line("Hello\r\nworld\t there"), "Hello world there")
self.assertEqual(common.one_line("zero\u200bwidth"), "zerowidth")
long = common.one_line("x" * 400)
self.assertEqual(len(long), 180)
self.assertTrue(long.endswith("…"))
def test_encode_decode_chat_id(self) -> None:
opaque = common.encode_id("!NCdzlIaMjZUmvmvyHU:beeper.com")
self.assertTrue(opaque.startswith("beeper:"))
self.assertNotIn("=", opaque)
self.assertEqual(common.decode_id(opaque), "!NCdzlIaMjZUmvmvyHU:beeper.com")
def test_encode_decode_survives_slashes_and_plus(self) -> None:
raw = "!a+b/c=d:local-whatsapp.localhost"
self.assertEqual(common.decode_id(common.encode_id(raw)), raw)
def test_decode_id_rejects_junk(self) -> None:
for bad in ("", "nope", "beeper:", "other:aGk", "beeper:!!!!", "beeper:" + "A" * 4):
payload = capture_json(common.decode_id, bad)
self.assertFalse(payload["ok"], msg=bad)
self.assertEqual(payload["error"], "not a chat id")
def test_api_base_is_local_only(self) -> None:
self.assertEqual(common.API_BASE, "http://localhost:23373")
class HttpBodyTests(unittest.TestCase):
def _body(self, data: bytes, content_length: object | None = ""):
class Body:
def __init__(self) -> None:
self._buf = BytesIO(data)
self.headers = {}
if content_length != "":
self.headers["Content-Length"] = content_length
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
return Body()
def test_accepts_body_at_limit(self) -> None:
raw = common.read_http_body(self._body(b"a" * 16), 16)
self.assertEqual(raw, b"a" * 16)
def test_rejects_body_over_limit(self) -> None:
with self.assertRaises(common.ResponseTooLargeError):
common.read_http_body(self._body(b"a" * 17), 16)
def test_rejects_declared_length_over_limit(self) -> None:
with self.assertRaises(common.ResponseTooLargeError):
common.read_http_body(self._body(b"a", content_length="99999"), 16)
def test_ignores_bogus_declared_length(self) -> None:
raw = common.read_http_body(self._body(b"ab", content_length="not-a-number"), 16)
self.assertEqual(raw, b"ab")
def test_default_caps_are_the_documented_ones(self) -> None:
self.assertEqual(common.MAX_HTTP_BODY, 2 * 1024 * 1024)
self.assertEqual(common.MAX_HTTP_ERROR, 64 * 1024)
self.assertEqual(common.MAX_LOCAL_FILE, 64 * 1024)
class SecretFileTests(unittest.TestCase):
def setUp(self) -> None:
self.dir = _workdir()
self.path = self.dir / "token.json"
self.addCleanup(shutil.rmtree, self.dir, True)
def _write(self, text: str, mode: int = 0o600) -> None:
self.path.write_text(text, encoding="utf-8")
os.chmod(self.path, mode)
os.chmod(self.dir, 0o700)
def test_missing_token_file_is_empty_not_an_error(self) -> None:
with patch.object(common, "TOKEN_FILE", self.path):
self.assertEqual(common.load_token(), "")
def test_reads_private_token(self) -> None:
self._write(json.dumps({"token": "abc123"}))
with patch.object(common, "TOKEN_FILE", self.path):
self.assertEqual(common.load_token(), "abc123")
def test_rejects_group_or_world_readable_token(self) -> None:
self._write(json.dumps({"token": "abc123"}), mode=0o644)
with patch.object(common, "TOKEN_FILE", self.path):
payload = capture_json(common.load_token)
self.assertFalse(payload["ok"])
self.assertIn("too open", payload["error"])
def test_rejects_too_open_secrets_directory(self) -> None:
self._write(json.dumps({"token": "abc123"}))
os.chmod(self.dir, 0o755)
try:
with patch.object(common, "TOKEN_FILE", self.path):
payload = capture_json(common.load_token)
finally:
os.chmod(self.dir, 0o700)
self.assertFalse(payload["ok"])
self.assertIn("chmod 700", payload["error"])
def test_rejects_invalid_json(self) -> None:
self._write("{not json")
with patch.object(common, "TOKEN_FILE", self.path):
payload = capture_json(common.load_token)
self.assertFalse(payload["ok"])
self.assertIn("not valid JSON", payload["error"])
def test_rejects_oversized_token_file(self) -> None:
self._write("x" * (common.MAX_LOCAL_FILE + 1))
with patch.object(common, "TOKEN_FILE", self.path):
payload = capture_json(common.load_token)
self.assertFalse(payload["ok"])
self.assertIn("too large", payload["error"])
def test_symlinked_token_file_is_not_followed(self) -> None:
target = self.dir / "real.json"
target.write_text(json.dumps({"token": "leak"}), encoding="utf-8")
os.chmod(target, 0o600)
link = self.dir / "link.json"
link.symlink_to(target)
with patch.object(common, "TOKEN_FILE", link):
self.assertEqual(common.load_token(), "")
def test_save_token_writes_600_and_survives_replace(self) -> None:
with patch.object(common, "CONFIG_DIR", self.dir), patch.object(
common, "SECRETS_DIR", self.dir
), patch.object(common, "TOKEN_FILE", self.path):
common.save_token("first")
common.save_token("second")
self.assertEqual(common.load_token(), "second")
mode = stat.S_IMODE(self.path.stat().st_mode)
self.assertEqual(mode, 0o600)
self.assertEqual(len(list(self.dir.glob("*.tmp"))), 0)
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import json
import unittest
from support import ROOT # noqa: I001 - puts lib/ on sys.path
class PanelContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.qml = (ROOT / "Panel.qml").read_text(encoding="utf-8")
cls.icon = (ROOT / "BeeperIcon.qml").read_text(encoding="utf-8")
cls.manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
cls.readme = (ROOT / "README.md").read_text(encoding="utf-8")
def test_panel_only_ever_runs_the_cli(self) -> None:
self.assertIn('Qt.resolvedUrl("bin/omarchy-beeper")', self.qml)
self.assertIn('listProc.command = argv', self.qml)
self.assertIn('readProc.command = [root.script, "read", id]', self.qml)
self.assertIn('readAllProc.command = [root.script, "read-all"]', self.qml)
self.assertIn('openProc.command = [root.script, "open", message.id]', self.qml)
self.assertIn('openProc.command = [root.script, "open"]', self.qml)
def test_passes_limit_from_widget_settings(self) -> None:
self.assertIn('setting("max", 25)', self.qml)
self.assertIn('setting("refreshIntervalSec", 60)', self.qml)
self.assertIn('"--limit"', self.qml)
self.assertIn('"--page"', self.qml)
def test_settings_schema_matches_the_panel_clamps(self) -> None:
schema = {item["key"]: item for item in self.manifest["barWidget"]["schema"]}
self.assertEqual((schema["max"]["min"], schema["max"]["max"]), (1, 50))
self.assertEqual(schema["max"]["defaultValue"], 25)
self.assertEqual(
(schema["refreshIntervalSec"]["min"], schema["refreshIntervalSec"]["max"]), (15, 3600)
)
self.assertEqual(schema["refreshIntervalSec"]["defaultValue"], 60)
self.assertEqual(self.manifest["barWidget"]["defaults"], {"max": 25, "refreshIntervalSec": 60})
self.assertEqual(self.manifest["kinds"], ["bar-widget"])
self.assertEqual(self.manifest["entryPoints"]["barWidget"], "Panel.qml")
self.assertEqual(self.manifest["schemaVersion"], 1)
self.assertIn("Math.min(50, n)", self.qml)
self.assertIn("Math.min(3600, n)", self.qml)
def test_every_untrusted_string_is_plain_text(self) -> None:
# One `textFormat: Text.PlainText` per Text that renders API data.
self.assertGreaterEqual(self.qml.count("textFormat: Text.PlainText"), 12)
for banned in ("Text.RichText", "Text.StyledText", "Text.MarkdownText"):
self.assertNotIn(banned, self.qml)
def test_ids_are_validated_before_use(self) -> None:
self.assertIn("/^beeper:[A-Za-z0-9_-]{1,1024}$/", self.qml)
self.assertIn("/^[0-9]{1,9}$/", self.qml)
self.assertIn("if (!message || !validId(message.id)) return", self.qml)
self.assertIn("nextPage = validToken(data.nextPage)", self.qml)
def test_row_click_opens_dismisses_and_marks_read(self) -> None:
self.assertIn("function openChat(message)", self.qml)
self.assertRegex(
self.qml,
r"openProc\.running = true\n dismissLocal\(message\.id\)\n enqueueRead\(message\.id\)\n close\(\)",
)
def test_local_dismiss_and_read_queue_survive_a_slow_api(self) -> None:
self.assertIn("property var dismissedIds", self.qml)
self.assertIn("property var readQueue", self.qml)
self.assertIn("property string pendingId", self.qml)
self.assertIn("function enqueueRead(", self.qml)
self.assertIn("function pumpRead()", self.qml)
self.assertIn("unread = Math.max(0, (data.unread || 0) - dropped)", self.qml)
self.assertNotIn("unread = 0", self.qml)
self.assertNotIn("messages = []", self.qml)
def test_keyboard_map_is_complete(self) -> None:
self.assertIn("onMoveRequested", self.qml)
self.assertIn("onActivateRequested: root.activateCursor()", self.qml)
self.assertIn("onTabRequested", self.qml)
self.assertIn("onCloseRequested", self.qml)
for key in ("o", "i", "a", "A", "n", "p"):
self.assertIn(f't === "{key}"', self.qml)
self.assertRegex(self.qml, r't === "a"\)\s+root\.markCursorRead\(\)')
self.assertRegex(self.qml, r't === "A"\)\s+root\.requestMarkAll\(\)')
def test_mark_all_needs_two_presses_and_shows_progress(self) -> None:
self.assertIn("Mark all unread as read (A)", self.qml)
self.assertIn("Click again to confirm", self.qml)
self.assertIn("Marking unread chats as read…", self.qml)
self.assertIn("property bool markAllArmed", self.qml)
self.assertIn("property bool markAllBusy", self.qml)
self.assertIn("applyReadAllPayload", self.qml)
self.assertIn("opacity: root.markAllBusy ? 0.4 : 1", self.qml)
self.assertIn("markAllArmTimer.restart()", self.qml)
def test_unreachable_state_dims_the_icon_and_keeps_the_list(self) -> None:
self.assertIn("opacity: root.reachable ? 1 : 0.5", self.qml)
self.assertIn("Could not reach Beeper. Showing the last list.", self.qml)
self.assertIn("if (!reachable) return", self.qml)
self.assertIn("Beeper unreachable", self.qml)
def test_tooltip_counts_unread_chats(self) -> None:
self.assertIn("function tooltipFor()", self.qml)
self.assertIn("tooltipText: root.tooltipFor()", self.qml)
self.assertIn('return root.unread + " unread chats"', self.qml)
self.assertIn('return "No unread chats"', self.qml)
def test_badge_uses_bar_foreground_never_the_urgent_colour(self) -> None:
self.assertIn("color: button.foreground", self.qml)
self.assertIn("dotColor: button.foreground", self.qml)
self.assertIn(
"color: Qt.rgba(button.foreground.r, button.foreground.g,\n button.foreground.b, 0.14)",
self.qml,
)
self.assertNotIn("button.activeColor", self.qml)
self.assertNotIn("badgeCount\n color: bar.urgent", self.qml)
self.assertNotIn("active: root.opened", self.qml)
def test_network_chip_only_shows_with_more_than_one_account(self) -> None:
self.assertIn("visible: root.accountCount > 1", self.qml)
self.assertIn("(row.modelData.labels || []).slice(0, 1)", self.qml)
self.assertIn("elide: Text.ElideRight", self.qml)
self.assertIn("Style.space(64)", self.qml)
self.assertIn("Math.max(Style.space(40)", self.qml)
def test_icon_is_a_stroked_bubble_with_no_layer_effects(self) -> None:
self.assertIn("ctx.stroke()", self.icon)
self.assertIn("ctx.arcTo(", self.icon)
self.assertIn("dotAmount", self.icon)
self.assertIn("snapStroke", self.icon)
self.assertNotIn("layer.enabled", self.icon)
self.assertNotIn("Image {", self.icon)
def test_bubble_and_dots_stay_inside_the_canvas(self) -> None:
for size in (10, 12, 16, 20, 24, 32, 48):
for dpr in (1.0, 1.25, 1.5, 2.0):
geom = bubble_geometry(size, dpr)
self.assertGreater(geom["bodyH"], 0, msg=f"size={size} dpr={dpr}")
self.assertGreater(geom["tailRight"], geom["tailLeft"])
for amount in (0.0, 0.5, 1.0):
for x, y in painted_extents(geom, amount):
self.assertGreaterEqual(x, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
self.assertGreaterEqual(y, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
self.assertLessEqual(x, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
self.assertLessEqual(y, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
def test_tail_never_runs_into_a_corner_arc(self) -> None:
for size in (10, 16, 24, 48):
geom = bubble_geometry(size, 1.0)
self.assertGreaterEqual(geom["tailLeft"], geom["bodyX"] + geom["radius"] - 0.01)
self.assertLessEqual(
geom["tailRight"], geom["bodyX"] + geom["bodyW"] - geom["radius"] + 0.01
)
def test_readme_documents_install_setup_and_removal(self) -> None:
self.assertIn("https://github.com/prdlk/omarchy-beeper.git", self.readme)
self.assertIn("omarchy plugin add", self.readme)
self.assertIn("--enable", self.readme)
self.assertIn("omarchy plugin update prdlk.beeper", self.readme)
self.assertIn("omarchy-beeper auth", self.readme)
self.assertIn("~/.config/omarchy-beeper/", self.readme)
self.assertIn("Settings", self.readme)
self.assertIn("Integrations", self.readme)
self.assertIn("revoke", self.readme)
self.assertIn("muted", self.readme)
self.assertIn("archived", self.readme)
self.assertIn("low-priority", self.readme)
self.assertIn("indexing", self.readme)
self.assertIn("never sends", self.readme.lower())
self.assertIn("preview.png", self.readme)
self.assertTrue((ROOT / "preview.png").is_file(), "marketplace listing wants preview.png")
self.assertTrue((ROOT / "docs" / "SETUP.md").is_file())
self.assertTrue((ROOT / "LICENSE").is_file())
def test_readme_key_map_matches_the_panel(self) -> None:
for key in ("`j`", "`k`", "`o`", "`a`", "`A`", "`n`", "`p`", "`i`", "`Tab`", "`Esc`"):
self.assertIn(key, self.readme)
def test_changelog_matches_manifest(self) -> None:
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
self.assertIn(f"## {self.manifest['version']}", changelog)
def _snap(value: float, dpr: float) -> float:
return round(value * dpr) / dpr
def _snap_stroke(value: float, dpr: float) -> float:
return max(1, round(value * dpr)) / dpr
def bubble_geometry(icon_size: float, dpr: float = 1.0) -> dict[str, float]:
"""Mirror of BeeperIcon.qml so the geometry can be checked without a GPU."""
stroke = _snap_stroke(max(1.5, icon_size * 0.11), dpr)
pad = _snap(max(stroke / 2, 0.5), dpr)
tail_h = _snap(max(stroke * 1.2, icon_size * 0.16), dpr)
body_x = _snap(pad, dpr)
body_y = _snap(pad, dpr)
body_w = _snap(icon_size - pad * 2, dpr)
body_h = _snap(max(stroke * 3, icon_size - pad * 2 - tail_h), dpr)
radius = _snap(min(body_w, body_h) * 0.3, dpr)
tail_left = _snap(body_x + max(radius, body_w * 0.2), dpr)
tail_right = _snap(tail_left + max(stroke, body_w * 0.2), dpr)
tail_tip_x = _snap(tail_left + max(stroke / 2, body_w * 0.04), dpr)
dot_r = _snap(max(1, min(icon_size * 0.075, body_h * 0.16)), dpr)
dot_gap = _snap(max(dot_r * 2.2, body_w * 0.24), dpr)
dot_y = _snap(body_y + body_h / 2, dpr)
return {
"size": icon_size,
"stroke": stroke,
"pad": pad,
"tailH": tail_h,
"bodyX": body_x,
"bodyY": body_y,
"bodyW": body_w,
"bodyH": body_h,
"radius": min(radius, body_w / 2, body_h / 2),
"tailLeft": tail_left,
"tailRight": tail_right,
"tailTipX": tail_tip_x,
"dotR": dot_r,
"dotGap": dot_gap,
"dotY": dot_y,
}
def painted_extents(geom: dict[str, float], dot_amount: float) -> list[tuple[float, float]]:
"""Outermost painted points, stroke width included."""
half = geom["stroke"] / 2
x = geom["bodyX"]
y = geom["bodyY"]
w = geom["bodyW"]
h = geom["bodyH"]
points = [
(x - half, y - half),
(x + w + half, y - half),
(x + w + half, y + h + half),
(x - half, y + h + half),
(geom["tailTipX"] - half, y + h + geom["tailH"] + half),
(geom["tailTipX"] + half, y + h + geom["tailH"] + half),
]
if dot_amount > 0.01:
scaled = geom["dotR"] * (0.6 + 0.4 * dot_amount)
cx = x + w / 2
for i in (-1, 0, 1):
points.append((cx + i * geom["dotGap"] - scaled, geom["dotY"] - scaled))
points.append((cx + i * geom["dotGap"] + scaled, geom["dotY"] + scaled))
return points
if __name__ == "__main__":
unittest.main()