From 2d6dab868a72a8bf0ae47c1e0b21280017ccce5e Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 10 Aug 2026 07:21:04 +0800 Subject: [PATCH] emrg: auto update-check prompt (rant 2026-08-10T07:12:12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display-only new-version check — never auto-downloads/installs. - emrg/update_check.py: version compare (semver tuple, prerelease-safe), TTL gate, idempotent prompt state (~/.emrg/.last_update_check.json), silent api.github.com check (no raise on failure) - config: [update] check=true|false + ttl_hours=24 (default true) - daemon: _update_check_loop at startup + TTL; update_check / update_check_prompted commands - TUI: one-time non-blocking startup banner; marks prompted (idempotent) - GUI: settings about-area line with Releases link (one-time) - +18 tests (version/TTL/idempotency/silent-failure/disable), 652->670 --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- emrg/client/app.py | 30 +++++ emrg/config.py | 43 +++++- emrg/gui/main.js | 26 ++++ emrg/gui/preload.js | 2 + emrg/gui/renderer/index.html | 1 + emrg/gui/renderer/js/dialogs.js | 36 +++++ emrg/gui/renderer/js/i18n.js | 2 + emrg/server/daemon.py | 74 ++++++++++ emrg/update_check.py | 150 +++++++++++++++++++++ tests/test_update_check.py | 230 ++++++++++++++++++++++++++++++++ 13 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 emrg/update_check.py create mode 100644 tests/test_update_check.py diff --git a/Agent.md b/Agent.md index 9b5e01c7..f9439f0f 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (652) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (670) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (107: 29 daemon_client + 22 app-commands + 31 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index a22b5d77..c2c8ede7 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 652 项) +uv run pytest tests/ -v # 跑测试(当前 670 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index e376e78f..7ddfc00e 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 652 items) +uv run pytest tests/ -v # run tests (currently 670 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/client/app.py b/emrg/client/app.py index f2aeea4d..d83189ad 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -356,6 +356,13 @@ async def _reconnect(): import emrg ver = getattr(emrg, "__version__", "dev") chat.add("system", f"EMRG {ver} | {server_id}\nType /help for shortcuts, or just start chatting.") + # Auto update-check prompt (rant 2026-08-10T07:12:12): + # one-time, non-blocking — query daemon's cached latest + # release, show a status line, mark prompted (idempotent). + try: + await conn.send_command("update_check") + except Exception: + pass # never block chat on update check status.update(left=_status_left(session_title, session_id), center=server_id) term.set_title(f"{session_title or session_id} @ {project_name}") term.render(); continue @@ -906,6 +913,29 @@ async def _reconnect(): continue # Memory content (read) + if data.get("type") == "update_check": + # Auto update-check prompt (rant 2026-08-10T07:12:12): + # one-time, non-blocking status line; idempotent per version. + if data.get("has_update") and data.get("enabled"): + latest = data.get("latest_version", "") + prompted = data.get("prompted_version", "") + if latest and latest != prompted: + import emrg + ver = getattr(emrg, "__version__", "dev") + chat.add("system", + f"New version v{latest} available (current v{ver}) — " + f"https://github.com/argszero/emrg/releases") + status.update(center=server_id or "emrg") + try: + await conn.send_command( + "update_check_prompted", + {"version": latest}, + ) + except Exception: + pass + term.render() + continue + if data.get("type") == "memory_content": err = data.get("error", "") if err: diff --git a/emrg/config.py b/emrg/config.py index 2ed6efb0..9e57396a 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -37,9 +37,23 @@ class LlmConfig: stream_options: Optional[dict] = field(default_factory=lambda: {"include_usage": False}) +@dataclass +class UpdateConfig: + """Auto update-check settings (rant 2026-08-10T07:12:12). + + check: master switch — when false, the daemon never queries GitHub. + ttl_hours: how often to re-check the latest release (default 24h). + Prompting is always display-only (no auto download/install). + """ + + check: bool = True + ttl_hours: int = 24 + + @dataclass class EmrgConfig: llm: LlmConfig = field(default_factory=LlmConfig) + update: UpdateConfig = field(default_factory=UpdateConfig) def config_dir() -> Path: @@ -92,7 +106,34 @@ def load_config() -> EmrgConfig: var_name = llm.api_key[2:-1] llm.api_key = os.environ.get(var_name, llm.api_key) - return EmrgConfig(llm=llm) + update_data = data.get("update", {}) + update = UpdateConfig( + check=update_data.get("check", True), + ttl_hours=update_data.get("ttl_hours", 24), + ) + + return EmrgConfig(llm=llm, update=update) + + +def load_update_config() -> UpdateConfig: + """Load only the [update] section (rant 2026-08-10T07:12:12). + + The daemon is constructed with just LlmConfig; this helper lets it read + the update-check switch without parsing the full config. Missing config + file or missing section → defaults (check=True, ttl=24h). + """ + cfg_path = config_path() + if not cfg_path.exists(): + return UpdateConfig() + try: + data = tomllib.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return UpdateConfig() + update_data = data.get("update", {}) + return UpdateConfig( + check=update_data.get("check", True), + ttl_hours=update_data.get("ttl_hours", 24), + ) def ensure_config() -> None: diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 7b211ab7..4735f26d 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -459,6 +459,32 @@ vision = false return { authenticated: Boolean(frame.authenticated), user: frame.user || null }; }); + ipcMain.handle("emrg:updateCheck", async () => { + // Auto update-check prompt (rant 2026-08-10T07:12:12): query daemon's + // cached latest release; display-only, no auto download/install. + try { + const frame = await client.sendCommandAndWait("update_check", {}, 10000); + return { + current_version: frame.current_version || "", + latest_version: frame.latest_version || "", + has_update: Boolean(frame.has_update), + prompted_version: frame.prompted_version || "", + enabled: Boolean(frame.enabled), + }; + } catch { + return { has_update: false, enabled: false, latest_version: "", current_version: "" }; + } + }); + + ipcMain.handle("emrg:updateCheckPrompted", async (_e, { version }) => { + // Idempotency (rant 07:12:12 §4): record that the GUI showed the prompt + // for this version — same version never re-prompted. + try { + await client.sendCommandAndWait("update_check_prompted", { version: String(version || "") }, 5000); + } catch { /* best-effort */ } + return { ok: true }; + }); + ipcMain.handle("emrg:githubConnect", async (_e, { token }) => { // Windows GCM rant Stage 2:PAT 授权 + setup-git(daemon github_connect) const frame = await client.sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 1ccc6678..3a076d97 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -28,6 +28,8 @@ const api = { sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload), githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), + updateCheck: () => ipcRenderer.invoke("emrg:updateCheck"), + updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index e69ff749..d6341d0a 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -164,6 +164,7 @@

设置

关于
EMRG v0.2.8 · 🌱 已自我进化 0
+
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index f9c759b1..91f07dbc 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -232,6 +232,9 @@ const Dialogs = (() => { } catch { /* 元素缺失(测试桩)时忽略 */ } // Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新 await refreshGithubStatus(); + // Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows + // a one-time non-intrusive line when a newer release exists. + await refreshUpdateCheck(); } catch (e) { Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message })); } @@ -485,6 +488,39 @@ const Dialogs = (() => { } } + // Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one + // line in the about area, never a modal — no auto download/install. + async function refreshUpdateCheck() { + const el = $("about-update"); + if (!el) return; // 元素缺失(测试桩)时忽略 + try { + const u = await window.emrg.updateCheck(); + if (!u || !u.enabled || !u.has_update || !u.latest_version) { + el.classList.add("hidden"); + el.textContent = ""; + return; + } + if (u.latest_version === u.prompted_version) { + el.classList.add("hidden"); + el.textContent = ""; + return; + } + const link = el("a", { + href: "https://github.com/argszero/emrg/releases", + target: "_blank", + rel: "noopener", + }, _t("settings.updateAvailable", { latest: u.latest_version })); + el.textContent = ""; + el.appendChild(link); + el.classList.remove("hidden"); + // 幂等:同版本只提示一次 + try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } + } catch { + el.classList.add("hidden"); + el.textContent = ""; + } + } + // ── 确认对话框(替代 confirm/alert) ──── let confirmCb = null; function showConfirm(title, message, opts = {}) { diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 9039cbec..093037dc 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -89,6 +89,7 @@ const I18N = (() => { "settings.githubConnectedStatus": "已连接 @{user}", "settings.githubNotConnected": "未连接", "settings.githubStatusFailed": "状态获取失败", + "settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)", "settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token", "settings.githubConnecting": "连接中…", "settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)", @@ -380,6 +381,7 @@ const I18N = (() => { "settings.githubConnectedStatus": "Connected as @{user}", "settings.githubNotConnected": "Not connected", "settings.githubStatusFailed": "Failed to load status", + "settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)", "settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first", "settings.githubConnecting": "Connecting…", "settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)", diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 82453d02..2f84ca84 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -292,12 +292,19 @@ async def serve(self) -> None: # host-modified skill copies. self._skills_ttl_task = asyncio.create_task(self._skills_ttl_loop()) + # Auto update-check prompt (rant 2026-08-10T07:12:12): runs at startup + # + every [update] ttl_hours (default 24h). ONLY checks the latest + # release and persists state — clients (TUI/GUI) decide how to show + # the one-time prompt. No auto download/install, silent on failure. + self._update_check_task = asyncio.create_task(self._update_check_loop()) + try: await self._server.serve_forever() except asyncio.CancelledError: pass finally: self._skills_ttl_task.cancel() + self._update_check_task.cancel() try: await self._skills_ttl_task except (asyncio.CancelledError, Exception): @@ -369,6 +376,38 @@ async def _skills_ttl_loop(self) -> None: logger.warning("skills update errors: %s", result["errors"]) await asyncio.sleep(_UPDATE_TTL_SECONDS) + async def _update_check_loop(self) -> None: + """Background auto update-check prompt (rant 2026-08-10T07:12:12). + + Runs at startup + every [update] ttl_hours (default 24h). ONLY checks + the latest release via api.github.com and persists state to + ~/.emrg/.last_update_check.json — no auto download/install. Failures + are silent (never crash, never log noise); the next TTL retries. + Disabled entirely when [update] check = false in config.toml. + """ + from emrg.config import load_update_config + from emrg.update_check import ( + load_state, + run_update_check_once, + should_check, + ) + + update_cfg = load_update_config() + if not update_cfg.check: + logger.debug("auto update-check disabled by config ([update] check=false)") + return + + ttl = max(3600, int(update_cfg.ttl_hours or 24) * 3600) + while True: + state = load_state() + if should_check(state, ttl): + result = await run_update_check_once() + if result.get("checked"): + logger.debug( + "update check: latest=%s", result.get("latest_version") + ) + await asyncio.sleep(ttl) + def _evolution_count(self) -> int: """Total completed evolution cycles across scheduler handlers + disk. @@ -1354,6 +1393,41 @@ async def _process_message( auth = await self._check_github_auth() await self._send(ws, {"type": "github_status", **auth}) + elif msg_type == "update_check": + # Auto update-check prompt (rant 2026-08-10T07:12:12): TUI/GUI + # query the daemon's latest known release. Returns the cached + # latest_version (populated at startup + every TTL) plus a + # has_update flag computed against the running version. No auto + # download/install; the client shows a one-time prompt. + import emrg + from emrg.config import load_update_config + from emrg.update_check import is_newer, load_state, parse_version + + current = getattr(emrg, "__version__", "0") + state = load_state() + latest = state.get("latest_version") or "" + has_update = bool( + latest and is_newer(parse_version(latest), parse_version(current)) + ) + await self._send(ws, { + "type": "update_check", + "current_version": current, + "latest_version": latest, + "has_update": has_update, + "prompted_version": state.get("prompted_version") or "", + "enabled": load_update_config().check, + }) + + elif msg_type == "update_check_prompted": + # Idempotency (rant 07:12:12 §4): a client records that it showed + # the prompt for this version — same version never re-prompted. + from emrg.update_check import load_state, mark_prompted + + version = msg.get("version", "") + if version: + mark_prompted(load_state(), version) + await self._send(ws, {"type": "update_check_prompted", "ok": True}) + elif msg_type == "github_connect": # Windows GCM rant Stage 2: GUI PAT auth — gh auth login # --with-token + gh auth setup-git (git no longer touches GCM). diff --git a/emrg/update_check.py b/emrg/update_check.py new file mode 100644 index 00000000..6abf12eb --- /dev/null +++ b/emrg/update_check.py @@ -0,0 +1,150 @@ +"""Automatic update-check + notify (rant 2026-08-10T07:12:12). + +Design (host-specified boundary): ONLY check for new versions and PROMPT — +never auto-download, never auto-install, never start an installer flow. +The prompt is lightweight and non-intrusive (TUI status line / GUI settings +about area), one prompt per version (idempotent via state file), silent on +network failure (retry at next TTL). + +Check source: api.github.com (github.com:443 / raw.githubusercontent.com are +blocked on the host network — see git_utils / skills installer patterns). +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Optional + +import httpx + +from emrg.config import config_dir + +# Default TTL between checks (seconds). Host-configurable via [update] ttl_hours. +DEFAULT_TTL_SECONDS = 24 * 3600 +# API endpoint — releases/latest never includes prereleases (semver-satisfying). +RELEASES_LATEST_URL = "https://api.github.com/repos/argszero/emrg/releases/latest" +CHECK_TIMEOUT_SECONDS = 10.0 + +STATE_FILE_NAME = ".last_update_check.json" + + +def parse_version(tag: str) -> tuple: + """Parse a version tag like 'v0.2.18' into a numeric tuple for comparison. + + Only dot-separated pieces that are entirely digits are kept; the first + piece with any non-digit (prerelease/build suffix like '-beta1' or + '18-rc.2') terminates parsing — prerelease tags can never compare as + newer than a released version. Unparseable input → () (never newer). + """ + if not tag: + return () + s = tag.strip() + if s.startswith("v"): + s = s[1:] + parts = [] + for piece in s.split("."): + if piece.isdigit(): + parts.append(int(piece)) + else: + break # prerelease/build suffix — stop, drop the rest + return tuple(parts) + + +def is_newer(latest: tuple, current: tuple) -> bool: + """True iff latest > current (pure tuple comparison).""" + return bool(latest) and latest > current + + +# ── State file (~/.emrg/.last_update_check.json) ────────────────────────── +# {checked_at: float epoch, latest_version: "0.2.18"|None, +# prompted_version: "0.2.18"|None} +# - checked_at: last successful check timestamp (TTL gate) +# - latest_version: last known latest from GitHub +# - prompted_version: the version for which a prompt was already shown +# (idempotency: same version is only prompted once) + + +def state_path() -> Path: + return config_dir() / STATE_FILE_NAME + + +def load_state() -> dict: + try: + data = json.loads(state_path().read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except (OSError, json.JSONDecodeError): + pass + return {} + + +def save_state(state: dict) -> None: + try: + state_path().write_text( + json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8" + ) + except OSError: + pass # state file is best-effort — never crash on write failure + + +def should_check(state: dict, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> bool: + """True iff a check is due (no record, or last check older than TTL).""" + checked_at = state.get("checked_at") + if not isinstance(checked_at, (int, float)): + return True + return (time.time() - checked_at) >= ttl_seconds + + +def should_prompt(state: dict, latest_version: str, current_version: str) -> bool: + """True iff a prompt should be shown for this version (idempotent). + + Conditions: latest is parseable and newer than current, AND this exact + version was not already prompted before. + """ + if not latest_version: + return False + if not is_newer(parse_version(latest_version), parse_version(current_version)): + return False + return state.get("prompted_version") != latest_version + + +def mark_prompted(state: dict, version: str) -> dict: + """Record that a prompt was shown for `version` (mutates + persists).""" + state["prompted_version"] = version + save_state(state) + return state + + +async def check_latest_version(timeout: float = CHECK_TIMEOUT_SECONDS) -> Optional[str]: + """Fetch the latest release tag from api.github.com. + + Returns the tag_name (e.g. '0.2.18') or None on ANY failure — silent, + never raises, never logs noise. The caller retries at the next TTL. + """ + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(RELEASES_LATEST_URL) + if resp.status_code != 200: + return None + data = resp.json() + tag = data.get("tag_name") or "" + return tag.lstrip("v") if tag else None + except Exception: + return None + + +async def run_update_check_once(state: Optional[dict] = None) -> dict: + """One deterministic check cycle: fetch latest, persist state, return result. + + Never raises. Used by the daemon background loop and directly testable. + """ + state = state if state is not None else load_state() + latest = await check_latest_version() + if latest is None: + return {"checked": False, "latest_version": None, "state": state} + state["checked_at"] = time.time() + state["latest_version"] = latest + save_state(state) + return {"checked": True, "latest_version": latest, "state": state} diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 00000000..d0d69fd0 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,230 @@ +"""Auto update-check prompt tests (rant 2026-08-10T07:12:12). + +Covers the host-specified acceptance items: +- version comparison (0.2.17 < 0.2.18) +- TTL logic (not expired → no check) +- prompt idempotency (same version not repeated) +- silent failure on network/API errors (no raise, next TTL retries) +- [update] check=false disables the daemon loop +""" + +from __future__ import annotations + +import asyncio +import json +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from emrg.update_check import ( + DEFAULT_TTL_SECONDS, + check_latest_version, + is_newer, + load_state, + mark_prompted, + parse_version, + run_update_check_once, + save_state, + should_check, + should_prompt, + state_path, +) + + +# ── version comparison ──────────────────────────────────────────────────── + +def test_parse_version_basic(): + assert parse_version("v0.2.18") == (0, 2, 18) + assert parse_version("0.2.17") == (0, 2, 17) + assert parse_version("v1.0") == (1, 0) + assert parse_version("") == () + + +def test_parse_version_drops_prerelease_suffix(): + # releases/latest never returns prereleases, but the parser is defensive: + # a prerelease tag terminates at the non-numeric piece → truncated tuple + # can never compare as NEWER than a released version (safe semantic). + assert parse_version("v0.2.17-beta1") == (0, 2) + assert parse_version("0.2.18-rc.2") == (0, 2) + # prerelease must never trigger a "new version" prompt + assert is_newer(parse_version("0.2.18-rc.2"), parse_version("0.2.17")) is False + + +def test_is_newer(): + assert is_newer((0, 2, 18), (0, 2, 17)) is True + assert is_newer((0, 2, 17), (0, 2, 18)) is False + assert is_newer((0, 2, 17), (0, 2, 17)) is False + assert is_newer((), (0, 2, 17)) is False # unparseable never newer + + +def test_should_prompt_acceptance(): + # 0.2.17 current, latest 0.2.18, not yet prompted → prompt + state = {} + assert should_prompt(state, "0.2.18", "0.2.17") is True + # same version already prompted → no repeat (idempotency) + state = {"prompted_version": "0.2.18"} + assert should_prompt(state, "0.2.18", "0.2.17") is False + # up to date → no prompt + assert should_prompt({}, "0.2.17", "0.2.17") is False + # no latest → no prompt + assert should_prompt({}, "", "0.2.17") is False + + +# ── TTL logic ───────────────────────────────────────────────────────────── + +def test_should_check_missing_state_returns_true(): + assert should_check({}) is True + + +def test_should_check_fresh_state_returns_false(): + state = {"checked_at": time.time()} + assert should_check(state, DEFAULT_TTL_SECONDS) is False + + +def test_should_check_stale_state_returns_true(): + state = {"checked_at": time.time() - DEFAULT_TTL_SECONDS - 1} + assert should_check(state, DEFAULT_TTL_SECONDS) is True + + +def test_should_check_custom_ttl(): + state = {"checked_at": time.time() - 5000} + assert should_check(state, 3600) is True + assert should_check(state, 10000) is False + + +# ── state file ──────────────────────────────────────────────────────────── + +def test_state_roundtrip(tmp_path, monkeypatch): + from emrg import config as config_mod + + monkeypatch.setattr(config_mod, "config_dir", lambda: tmp_path) + # re-read the module-level state_path binding + from emrg import update_check as uc + + monkeypatch.setattr(uc, "state_path", lambda: tmp_path / ".last_update_check.json") + save_state({"checked_at": 123.0, "latest_version": "0.2.18"}) + assert load_state() == {"checked_at": 123.0, "latest_version": "0.2.18"} + + +def test_load_state_missing_file(tmp_path, monkeypatch): + from emrg import update_check as uc + + monkeypatch.setattr(uc, "state_path", lambda: tmp_path / "nope.json") + assert load_state() == {} + + +def test_load_state_corrupt_file(tmp_path, monkeypatch): + from emrg import update_check as uc + + p = tmp_path / "bad.json" + p.write_text("{not json", encoding="utf-8") + monkeypatch.setattr(uc, "state_path", lambda: p) + assert load_state() == {} + + +def test_mark_prompted_persists(tmp_path, monkeypatch): + from emrg import update_check as uc + + p = tmp_path / "state.json" + monkeypatch.setattr(uc, "state_path", lambda: p) + state = {"latest_version": "0.2.18"} + out = mark_prompted(state, "0.2.18") + assert out["prompted_version"] == "0.2.18" + assert json.loads(p.read_text(encoding="utf-8"))["prompted_version"] == "0.2.18" + + +# ── network check ───────────────────────────────────────────────────────── + +def test_check_latest_version_success(): + async def run(): + class _Resp: + status_code = 200 + + def json(self): + return {"tag_name": "v0.2.18"} + + client = AsyncMock() + client.get = AsyncMock(return_value=_Resp()) + client.__aenter__ = AsyncMock(return_value=client) + with patch( + "emrg.update_check.httpx.AsyncClient", + return_value=client, + ): + return await check_latest_version() + + assert asyncio.run(run()) == "0.2.18" + + +def test_check_latest_version_http_error_returns_none(): + async def run(): + class _Resp: + status_code = 500 + + def json(self): + return {} + + client = AsyncMock() + client.get = AsyncMock(return_value=_Resp()) + client.__aenter__ = AsyncMock(return_value=client) + with patch( + "emrg.update_check.httpx.AsyncClient", + return_value=client, + ): + return await check_latest_version() + + assert asyncio.run(run()) is None # silent, no raise + + +def test_check_latest_version_network_error_returns_none(): + async def run(): + with patch( + "emrg.update_check.httpx.AsyncClient", + side_effect=OSError("network unreachable"), + ): + return await check_latest_version() + + assert asyncio.run(run()) is None # silent, no raise + + +def test_run_update_check_once_failure_preserves_state(tmp_path, monkeypatch): + from emrg import update_check as uc + + p = tmp_path / "state.json" + monkeypatch.setattr(uc, "state_path", lambda: p) + with patch.object(uc, "check_latest_version", AsyncMock(return_value=None)): + result = asyncio.run(uc.run_update_check_once({})) + assert result["checked"] is False + assert result["latest_version"] is None + # failure must NOT persist a checked_at (next TTL retries immediately) + assert not p.exists() + + +def test_run_update_check_once_success_persists(tmp_path, monkeypatch): + from emrg import update_check as uc + + p = tmp_path / "state.json" + monkeypatch.setattr(uc, "state_path", lambda: p) + with patch.object(uc, "check_latest_version", AsyncMock(return_value="0.2.18")): + result = asyncio.run(uc.run_update_check_once({})) + assert result["checked"] is True + assert result["latest_version"] == "0.2.18" + saved = json.loads(p.read_text(encoding="utf-8")) + assert saved["latest_version"] == "0.2.18" + assert "checked_at" in saved + + +# ── daemon loop disable (config [update] check=false) ───────────────────── + +def test_update_check_loop_returns_when_disabled(): + """config [update] check=false → loop exits immediately (no network).""" + import emrg.server.daemon as daemon_mod + + async def run(): + server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) + with patch("emrg.config.load_update_config") as mock_cfg: + mock_cfg.return_value.check = False + await server._update_check_loop() + return True + + assert asyncio.run(run()) is True