From 66d1c96783e7f6d1339ca22124f2217888730e5e Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 27 Jul 2026 20:27:05 +0800 Subject: [PATCH] feat(update): add Release/Nightly channel switching - Add POST /api/lm/switch-channel endpoint with git init / ZIP fallback - Add _backup_git/_restore_git helpers with safe rollback - Version-info endpoint now returns has_git flag for auto-detection - Check-updates always returns releases (changelog) regardless of channel - Nightly mode shows 'N commits behind main' with commit hash and date - View on GitHub link points to /commits/main in nightly mode - Channel toggle UI with pill-style buttons in update modal - Confirmation dialog with Esc / backdrop-dismiss support - Channel derived from has_git on every page load, no localStorage - i18n: 11 new keys translated across 9 non-English locales - CSS: unified card-style sections in _base.css - Tests: 8 new tests covering switch-channel, nightly response, init_git_repo --- locales/de.json | 17 +- locales/en.json | 15 + locales/es.json | 19 +- locales/fr.json | 19 +- locales/he.json | 19 +- locales/ja.json | 17 +- locales/ko.json | 17 +- locales/ru.json | 19 +- locales/zh-CN.json | 17 +- locales/zh-TW.json | 17 +- py/routes/update_routes.py | 248 +++++++++++++--- static/css/components/modal/_base.css | 1 + static/css/components/modal/update-modal.css | 135 ++++++++- static/js/managers/UpdateService.js | 248 +++++++++++++--- templates/components/modals/update_modal.html | 14 + tests/routes/test_update_routes.py | 264 +++++++++++++++++- 16 files changed, 990 insertions(+), 96 deletions(-) diff --git a/locales/de.json b/locales/de.json index 96b57acb..9ce496dd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1752,6 +1752,12 @@ "checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.", "showNotifications": "Update-Benachrichtigungen anzeigen", "latestBadge": "Neueste", + "latestMain": "Main-Branch", + "channel": "Update-Kanal", + "channels": { + "release": "Release", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "Update wird vorbereitet...", "installing": "Update wird installiert...", @@ -1772,6 +1778,15 @@ "warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.", "enable": "Nightly Updates aktivieren" }, + "channelSwitch": { + "nightlyTitle": "Zu Nightly-Kanal wechseln", + "nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.", + "releaseTitle": "Zu Release-Kanal wechseln", + "releaseMessage": "Der Wechsel zu Release entfernt das Git-Repository und installiert die neueste stabile Version. Zukünftige Updates verwenden nur stabile Versionen.", + "switching": "Wechsle zu {channel}-Kanal...", + "completed": "Erfolgreich zu {channel}-Kanal gewechselt", + "failed": "Kanalwechsel fehlgeschlagen" + }, "banners": { "recent": "Neueste Mitteilungen", "empty": "Keine aktuellen Banner verfügbar.", @@ -2235,4 +2250,4 @@ "retry": "Wiederholen" } } -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index f94b2a66..ec191bd5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1752,6 +1752,12 @@ "checkingMessage": "Please wait while we check for the latest version.", "showNotifications": "Show update notifications", "latestBadge": "Latest", + "latestMain": "Latest main", + "channel": "Update Channel", + "channels": { + "release": "Release", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "Preparing update...", "installing": "Installing update...", @@ -1772,6 +1778,15 @@ "warning": "Warning: Nightly builds may contain experimental features and could be unstable.", "enable": "Enable Nightly Updates" }, + "channelSwitch": { + "nightlyTitle": "Switch to Nightly Channel", + "nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.", + "releaseTitle": "Switch to Release Channel", + "releaseMessage": "Switching to Release will remove the Git repository and install the latest stable release. Future updates will use stable releases only.", + "switching": "Switching to {channel} channel...", + "completed": "Successfully switched to {channel} channel", + "failed": "Failed to switch channel" + }, "banners": { "recent": "Recent messages", "empty": "No recent banners yet.", diff --git a/locales/es.json b/locales/es.json index 465f00d4..e98fba43 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1751,7 +1751,13 @@ "checkingUpdates": "Comprobando actualizaciones...", "checkingMessage": "Por favor espera mientras comprobamos la última versión.", "showNotifications": "Mostrar notificaciones de actualización", - "latestBadge": "Último", + "latestBadge": "Última", + "latestMain": "Rama main", + "channel": "Canal de actualizacion", + "channels": { + "release": "Release", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "Preparando actualización...", "installing": "Instalando actualización...", @@ -1772,6 +1778,15 @@ "warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.", "enable": "Habilitar actualizaciones nocturnas" }, + "channelSwitch": { + "nightlyTitle": "Cambiar a canal Nightly", + "nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.", + "releaseTitle": "Cambiar a canal Release", + "releaseMessage": "Cambiar a Release eliminara el repositorio Git e instalara la ultima version estable. Las futuras actualizaciones usaran solo versiones estables.", + "switching": "Cambiando a canal {channel}...", + "completed": "Cambio a canal {channel} exitoso", + "failed": "Error al cambiar de canal" + }, "banners": { "recent": "Notificaciones recientes", "empty": "No hay banners recientes.", @@ -2235,4 +2250,4 @@ "retry": "Reintentar" } } -} +} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index e12b200f..d6a7f0ba 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1751,7 +1751,13 @@ "checkingUpdates": "Vérification des mises à jour...", "checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.", "showNotifications": "Afficher les notifications de mise à jour", - "latestBadge": "Dernier", + "latestBadge": "Dernière", + "latestMain": "Branche main", + "channel": "Canal de mise a jour", + "channels": { + "release": "Release", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "Préparation de la mise à jour...", "installing": "Installation de la mise à jour...", @@ -1772,6 +1778,15 @@ "warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.", "enable": "Activer les mises à jour nightly" }, + "channelSwitch": { + "nightlyTitle": "Passer au canal Nightly", + "nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.", + "releaseTitle": "Passer au canal Release", + "releaseMessage": "Passer a Release supprimera le depot Git et installera la derniere version stable. Les futures mises a jour utiliseront uniquement des versions stables.", + "switching": "Passage au canal {channel}...", + "completed": "Basculement vers le canal {channel} reussi", + "failed": "Echec du changement de canal" + }, "banners": { "recent": "Messages récents", "empty": "Aucune bannière récente.", @@ -2235,4 +2250,4 @@ "retry": "Réessayer" } } -} +} \ No newline at end of file diff --git a/locales/he.json b/locales/he.json index f9c6c3d0..343599ef 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1751,7 +1751,13 @@ "checkingUpdates": "בודק עדכונים...", "checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.", "showNotifications": "הצג התראות עדכון", - "latestBadge": "עדכן", + "latestBadge": "אחרון", + "latestMain": "ענף main", + "channel": "ערוץ עדכון", + "channels": { + "release": "Release", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "מכין עדכון...", "installing": "מתקין עדכון...", @@ -1772,6 +1778,15 @@ "warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.", "enable": "הפעל עדכונים ליליים" }, + "channelSwitch": { + "nightlyTitle": "מעבר לערוץ Nightly", + "nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.", + "releaseTitle": "מעבר לערוץ Release", + "releaseMessage": "מעבר ל-Release יסיר את מאגר ה-Git ויתקין את הגרסה היציבה האחרונה. עדכונים עתידיים ישתמשו בגרסאות יציבות בלבד.", + "switching": "מעבר לערוץ {channel}...", + "completed": "המעבר לערוץ {channel} הושלם", + "failed": "החלפת ערוץ נכשלה" + }, "banners": { "recent": "הודעות אחרונות", "empty": "אין כרגע באנרים אחרונים.", @@ -2235,4 +2250,4 @@ "retry": "נסה שוב" } } -} +} \ No newline at end of file diff --git a/locales/ja.json b/locales/ja.json index eb3a47a2..9c7f9424 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1752,6 +1752,12 @@ "checkingMessage": "最新バージョンを確認しています。お待ちください。", "showNotifications": "更新通知を表示", "latestBadge": "最新", + "latestMain": "Main ブランチ", + "channel": "更新チャンネル", + "channels": { + "release": "リリース", + "nightly": "ナイトリー" + }, "updateProgress": { "preparing": "更新を準備中...", "installing": "更新をインストール中...", @@ -1772,6 +1778,15 @@ "warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。", "enable": "ナイトリー更新を有効にする" }, + "channelSwitch": { + "nightlyTitle": "ナイトリーチャンネルに切り替え", + "nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。", + "releaseTitle": "リリースチャンネルに切り替え", + "releaseMessage": "リリースに切り替えると、Gitリポジトリが削除され、最新の安定版がインストールされます。以降の更新は安定版のみが使用されます。", + "switching": "{channel} チャンネルに切り替え中...", + "completed": "{channel} チャンネルに切り替えました", + "failed": "チャンネルの切り替えに失敗しました" + }, "banners": { "recent": "最近の通知", "empty": "最近のバナーはありません。", @@ -2235,4 +2250,4 @@ "retry": "再試行" } } -} +} \ No newline at end of file diff --git a/locales/ko.json b/locales/ko.json index b966dc3f..960a2561 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1752,6 +1752,12 @@ "checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.", "showNotifications": "업데이트 알림 표시", "latestBadge": "최신", + "latestMain": "Main 브랜치", + "channel": "업데이트 채널", + "channels": { + "release": "릴리스", + "nightly": "나이틀리" + }, "updateProgress": { "preparing": "업데이트 준비 중...", "installing": "업데이트 설치 중...", @@ -1772,6 +1778,15 @@ "warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.", "enable": "나이틀리 업데이트 활성화" }, + "channelSwitch": { + "nightlyTitle": "나이틀리 채널로 전환", + "nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.", + "releaseTitle": "릴리스 채널로 전환", + "releaseMessage": "릴리스로 전환하면 Git 저장소가 제거되고 최신 안정 버전이 설치됩니다. 이후 업데이트는 안정 버전만 사용됩니다.", + "switching": "{channel} 채널로 전환 중...", + "completed": "{channel} 채널로 전환 완료", + "failed": "채널 전환 실패" + }, "banners": { "recent": "최근 알림", "empty": "최근 배너가 없습니다.", @@ -2235,4 +2250,4 @@ "retry": "다시 시도" } } -} +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index d90a20cc..9b0ec39f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1751,7 +1751,13 @@ "checkingUpdates": "Проверка обновлений...", "checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.", "showNotifications": "Показывать уведомления об обновлениях", - "latestBadge": "Последний", + "latestBadge": "Последняя", + "latestMain": "Ветка main", + "channel": "Канал обновлений", + "channels": { + "release": "Релиз", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "Подготовка обновления...", "installing": "Установка обновления...", @@ -1772,6 +1778,15 @@ "warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.", "enable": "Включить ночные обновления" }, + "channelSwitch": { + "nightlyTitle": "Переключиться на Nightly", + "nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.", + "releaseTitle": "Переключиться на Release", + "releaseMessage": "Переключение на Release удалит Git-репозиторий и установит последнюю стабильную версию. Будущие обновления будут использовать только стабильные версии.", + "switching": "Переключение на канал {channel}...", + "completed": "Успешно переключено на канал {channel}", + "failed": "Не удалось переключить канал" + }, "banners": { "recent": "Недавние уведомления", "empty": "Недавних баннеров нет.", @@ -2235,4 +2250,4 @@ "retry": "Повторить" } } -} +} \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9481f7c2..9c4e0848 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1752,6 +1752,12 @@ "checkingMessage": "请稍候,正在检查最新版本。", "showNotifications": "显示更新通知", "latestBadge": "最新", + "latestMain": "Main 分支", + "channel": "更新频道", + "channels": { + "release": "稳定版", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "正在准备更新...", "installing": "正在安装更新...", @@ -1772,6 +1778,15 @@ "warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。", "enable": "启用 Nightly 更新" }, + "channelSwitch": { + "nightlyTitle": "切换到 Nightly", + "nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。", + "releaseTitle": "切换到稳定版", + "releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。", + "switching": "正在切换到 {channel} 频道...", + "completed": "已切换到 {channel} 频道", + "failed": "切换频道失败" + }, "banners": { "recent": "最近的通知", "empty": "暂无最近的横幅通知。", @@ -2235,4 +2250,4 @@ "retry": "重试" } } -} +} \ No newline at end of file diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 347c9369..d130963c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1752,6 +1752,12 @@ "checkingMessage": "請稍候,正在檢查最新版本。", "showNotifications": "顯示更新通知", "latestBadge": "最新", + "latestMain": "Main 分支", + "channel": "更新頻道", + "channels": { + "release": "稳定版", + "nightly": "Nightly" + }, "updateProgress": { "preparing": "正在準備更新...", "installing": "正在安裝更新...", @@ -1772,6 +1778,15 @@ "warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。", "enable": "啟用 Nightly 更新" }, + "channelSwitch": { + "nightlyTitle": "切换到 Nightly", + "nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。", + "releaseTitle": "切换到稳定版", + "releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。", + "switching": "正在切換到 {channel} 頻道...", + "completed": "已切換到 {channel} 頻道", + "failed": "切換頻道失敗" + }, "banners": { "recent": "最新通知", "empty": "目前沒有最近的橫幅通知。", @@ -2235,4 +2250,4 @@ "retry": "重試" } } -} +} \ No newline at end of file diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py index 683108eb..ee467759 100644 --- a/py/routes/update_routes.py +++ b/py/routes/update_routes.py @@ -47,6 +47,7 @@ class UpdateRoutes: app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates) app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info) app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update) + app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel) @staticmethod async def check_updates(request): @@ -65,10 +66,17 @@ class UpdateRoutes: # Fetch remote version from GitHub if nightly: - remote_version, changelog = await UpdateRoutes._get_nightly_version() - releases = None + local_hash = git_info.get('short_hash', '') + nightly_version, releases_result = await asyncio.gather( + UpdateRoutes._get_nightly_version(local_hash), + UpdateRoutes._get_remote_version() + ) + remote_version, _, behind_by, commit_date = nightly_version + _, changelog, releases = releases_result else: remote_version, changelog, releases = await UpdateRoutes._get_remote_version() + behind_by = 0 + commit_date = '' # Compare versions if nightly: @@ -81,6 +89,10 @@ class UpdateRoutes: remote_version.replace('v', '') ) + current_dir = os.path.dirname(os.path.abspath(__file__)) + plugin_root = os.path.dirname(os.path.dirname(current_dir)) + has_git = os.path.exists(os.path.join(plugin_root, '.git')) + response_data = { 'success': True, 'current_version': local_version, @@ -88,13 +100,13 @@ class UpdateRoutes: 'update_available': update_available, 'changelog': changelog, 'git_info': git_info, - 'nightly': nightly + 'nightly': nightly, + 'has_git': has_git, + 'releases': releases, + 'behind_by': behind_by, + 'commit_date': commit_date } - # Include releases list for stable mode - if releases is not None: - response_data['releases'] = releases - return web.json_response(response_data) except NETWORK_EXCEPTIONS as e: @@ -126,9 +138,14 @@ class UpdateRoutes: # Format: version-short_hash version_string = f"{local_version}-{short_hash}" + current_dir = os.path.dirname(os.path.abspath(__file__)) + plugin_root = os.path.dirname(os.path.dirname(current_dir)) + has_git = os.path.exists(os.path.join(plugin_root, '.git')) + return web.json_response({ 'success': True, - 'version': version_string + 'version': version_string, + 'has_git': has_git }) except Exception as e: @@ -190,6 +207,162 @@ class UpdateRoutes: 'error': str(e) }) + @staticmethod + async def switch_channel(request): + """ + Switch between release and nightly update channels. + + Release → Nightly: Initialize a Git repository (from ZIP/CM stable mode) + Nightly → Release: Remove .git, download latest release ZIP, write .tracking + """ + try: + body = await request.json() if request.has_body else {} + channel = body.get('channel', '') + + if channel not in ('release', 'nightly'): + return web.json_response({ + 'success': False, + 'error': f'Invalid channel: {channel}. Must be "release" or "nightly".' + }) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + plugin_root = os.path.dirname(os.path.dirname(current_dir)) + + settings_path = ensure_settings_file(logger) + settings_backup = None + if os.path.exists(settings_path): + with open(settings_path, 'r', encoding='utf-8') as f: + settings_backup = f.read() + logger.info("Backed up settings.json before channel switch") + + git_folder = os.path.join(plugin_root, '.git') + + if channel == 'nightly': + git_backup = None + if os.path.exists(git_folder): + git_backup = UpdateRoutes._backup_git(git_folder, 'nightly') + + success = False + new_version = '' + try: + if os.path.exists(git_folder): + success, new_version = await UpdateRoutes._perform_git_update( + plugin_root, nightly=True + ) + else: + success, new_version = UpdateRoutes._init_git_repo(plugin_root) + finally: + UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly') + else: + git_backup = None + if os.path.exists(git_folder): + git_backup = UpdateRoutes._backup_git(git_folder, 'release') + + success = False + new_version = '' + try: + if os.path.exists(git_folder): + shutil.rmtree(git_folder) + tracking_file = os.path.join(plugin_root, '.tracking') + if os.path.exists(tracking_file): + os.remove(tracking_file) + success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root) + finally: + UpdateRoutes._restore_git(git_backup, git_folder, success, 'release') + + if settings_backup and success: + with open(settings_path, 'w', encoding='utf-8') as f: + f.write(settings_backup) + logger.info("Restored settings.json after channel switch") + + if success: + return web.json_response({ + 'success': True, + 'channel': channel, + 'new_version': new_version, + 'message': f'Switched to {channel} channel' + }) + else: + return web.json_response({ + 'success': False, + 'error': f'Failed to switch to {channel} channel' + }) + + except Exception as e: + logger.error("Failed to switch channel: %s", e, exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }) + + @staticmethod + def _init_git_repo(plugin_root: str) -> tuple[bool, str]: + """ + Initialize a Git repository in a ZIP-installed plugin folder. + Clones the remote history and checks out main branch. + """ + try: + import git + except ImportError: + logger.error( + "GitPython is not available: cannot initialize git repo. " + "Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path." + ) + return False, "" + + clean_excludes = _clean_excludes() + + try: + repo = git.Repo.init(plugin_root) + origin = repo.create_remote( + 'origin', + 'https://github.com/willmiao/ComfyUI-Lora-Manager.git' + ) + origin.fetch() + + repo.create_head('main', origin.refs.main) + repo.git.checkout('main', '--force') + repo.git.reset('--hard') + repo.git.clean('-fd', *clean_excludes) + + tracking_file = os.path.join(plugin_root, '.tracking') + if os.path.exists(tracking_file): + os.remove(tracking_file) + logger.info("Removed .tracking file (now in git mode)") + + new_version = f"main-{repo.head.commit.hexsha[:7]}" + logger.info("Initialized git repo on main branch: %s", new_version) + return True, new_version + + except Exception as e: + logger.error("Failed to initialize git repo: %s", e, exc_info=True) + return False, "" + + @staticmethod + def _backup_git(git_folder, label): + try: + backup_dir = tempfile.mkdtemp() + backup = os.path.join(backup_dir, '.git') + shutil.copytree(git_folder, backup) + logger.info("Backed up .git before switching to %s", label) + return backup + except Exception as e: + logger.error("Failed to backup .git before %s switch: %s", label, e) + return None + + @staticmethod + def _restore_git(git_backup, git_folder, success, label): + if git_backup and not success: + try: + if os.path.exists(git_folder): + shutil.rmtree(git_folder) + shutil.copytree(git_backup, git_folder) + logger.info("Restored .git after failed %s switch", label) + except Exception as e: + logger.error("Failed to restore .git after %s switch: %s", label, e) + if git_backup: + shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True) + @staticmethod async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]: """ @@ -295,7 +468,8 @@ class UpdateRoutes: except Exception as e: logger.error(f"ZIP update failed: {e}", exc_info=True) return False, "" - + + @staticmethod def _clean_plugin_folder(plugin_root, skip_files=None): skip_files = skip_files or [] for item in os.listdir(plugin_root): @@ -308,41 +482,51 @@ class UpdateRoutes: os.remove(path) @staticmethod - async def _get_nightly_version() -> tuple[str, List[str]]: - """ - Fetch latest commit from main branch - """ + async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]: repo_owner = "willmiao" repo_name = "ComfyUI-Lora-Manager" - - # Use GitHub API to fetch the latest commit from main branch + github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main" - + try: downloader = await get_downloader() - success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'}) - + success, data = await downloader.make_request( + 'GET', github_url, + custom_headers={'Accept': 'application/vnd.github+json'} + ) + if not success: - logger.warning(f"Failed to fetch GitHub commit: {data}") - return "main", [] - - commit_sha = data.get('sha', '')[:7] # Short hash + logger.warning("Failed to fetch GitHub commit: %s", data) + return "main", [], 0, "" + + commit_sha = data.get('sha', '')[:7] commit_message = data.get('commit', {}).get('message', '') - - # Format as "main-{short_hash}" + commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10] + version = f"main-{commit_sha}" - - # Use commit message as changelog changelog = [commit_message] if commit_message else [] - - return version, changelog - + + behind_by = 0 + if local_hash and local_hash not in ('unknown', 'stable'): + compare_url = ( + f"https://api.github.com/repos/{repo_owner}/{repo_name}" + f"/compare/{local_hash}...main" + ) + c_ok, c_data = await downloader.make_request( + 'GET', compare_url, + custom_headers={'Accept': 'application/vnd.github+json'} + ) + if c_ok: + behind_by = c_data.get('behind_by', 0) + + return version, changelog, behind_by, commit_date + except NETWORK_EXCEPTIONS as e: logger.warning("Unable to reach GitHub for nightly version: %s", e) - return "main", [] + return "main", [], 0, "" except Exception as e: - logger.error(f"Error fetching nightly version: {e}", exc_info=True) - return "main", [] + logger.error("Error fetching nightly version: %s", e, exc_info=True) + return "main", [], 0, "" @staticmethod def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool: diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 940c08d2..19dca20c 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -151,6 +151,7 @@ body.modal-open { .support-section, .changelog-section, .update-info, +.update-channels, .info-item, .path-preview { background: var(--surface-subtle); diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css index da151057..bcc0b572 100644 --- a/static/css/components/modal/update-modal.css +++ b/static/css/components/modal/update-modal.css @@ -93,15 +93,13 @@ .update-content { display: flex; flex-direction: column; - gap: var(--space-3); + gap: var(--space-2); } .update-info { display: flex; justify-content: space-between; align-items: center; - border-radius: var(--border-radius-sm); - padding: var(--space-3); } .update-info .version-info { @@ -175,7 +173,6 @@ border: 1px solid var(--lora-border); border-radius: var(--border-radius-sm); padding: var(--space-2); - margin: var(--space-2) 0; } [data-theme="dark"] .update-progress { @@ -233,11 +230,6 @@ } /* Changelog section */ -.changelog-section { - border-radius: var(--border-radius-sm); - padding: var(--space-3); -} - .changelog-section h3 { margin-top: 0; margin-bottom: var(--space-2); @@ -349,6 +341,131 @@ text-decoration: underline; } +/* Channel Toggle */ +.update-channels { +} + +.channels-label { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; + margin-bottom: 8px; +} + +.channel-toggle { + display: flex; + gap: 0; + background: var(--lora-surface); + border-radius: 8px; + padding: 3px; + width: fit-content; +} + +.channel-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #999); + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; +} + +.channel-btn:hover { + color: var(--text-primary, #ddd); + background: rgba(255, 255, 255, 0.04); +} + +.channel-btn.active { + background: var(--lora-accent, #4285F4); + color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.channel-btn.active i { + color: #fff; +} + +.channel-btn i { + font-size: 0.85em; +} + +/* Channel Switch Confirmation Overlay */ +.channel-switch-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(2px); +} + +.channel-switch-dialog { + background: var(--lora-surface); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + border-radius: 12px; + padding: 28px 32px; + max-width: 420px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.channel-switch-dialog h3 { + margin: 0 0 12px; + font-size: 1.1em; + color: var(--text-primary, #eee); +} + +.channel-switch-dialog p { + margin: 0 0 24px; + font-size: 0.9em; + color: var(--text-secondary, #aaa); + line-height: 1.6; +} + +.channel-switch-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.channel-switch-cancel { + padding: 8px 18px; + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #aaa); + cursor: pointer; + font-size: 0.9em; +} + +.channel-switch-cancel:hover { + background: rgba(255, 255, 255, 0.04); +} + +.channel-switch-confirm { + padding: 8px 18px; + border: none; + border-radius: 6px; + background: var(--lora-accent, #4285F4); + color: #fff; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; +} + +.channel-switch-confirm:hover { + opacity: 0.9; +} + /* Update preferences section */ .update-preferences { border-top: 1px solid var(--lora-border); diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index b1dfbcb6..51d07a74 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -24,7 +24,9 @@ export class UpdateService { this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true); this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0'); this.isUpdating = false; - this.nightlyMode = getStorageItem('nightly_updates', false); + this.channelMode = null; + this.hasGit = false; + this.progressKeepVisible = false; this.currentVersionInfo = null; this.versionMismatch = false; this.activeNotificationTab = 'updates'; @@ -49,43 +51,161 @@ export class UpdateService { updateBtn.addEventListener('click', () => this.performUpdate()); } - // Register event listener for nightly update toggle - const nightlyCheckbox = document.getElementById('nightlyUpdateToggle'); - if (nightlyCheckbox) { - nightlyCheckbox.checked = this.nightlyMode; - nightlyCheckbox.addEventListener('change', (e) => { - this.nightlyMode = e.target.checked; - setStorageItem('nightly_updates', e.target.checked); - this.updateNightlyWarning(); - this.updateModalContent(); - // Re-check for updates when switching channels - this.manualCheckForUpdates(); - }); - this.updateNightlyWarning(); - } + this.wireChannelButtons(); this.setupNotificationCenter(); window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated); this.updateTabBadges(); // Perform update check if needed - this.checkForUpdates().then(() => { - // Ensure badges are updated after checking - this.updateBadgeVisibility(); + this.checkVersionInfo().then(() => { + if (this.channelMode === null) { + this.channelMode = this.hasGit ? 'nightly' : 'release'; + } + this.checkForUpdates().then(() => { + this.updateBadgeVisibility(); + }); }); - // Immediately update modal content with current values (even if from default) this.updateModalContent(); - - // Check version info for mismatch after loading basic info - this.checkVersionInfo(); } - updateNightlyWarning() { - const warning = document.getElementById('nightlyWarning'); - if (warning) { - warning.style.display = this.nightlyMode ? 'flex' : 'none'; + wireChannelButtons() { + const releaseBtn = document.getElementById('channelRelease'); + const nightlyBtn = document.getElementById('channelNightly'); + if (releaseBtn) { + releaseBtn.addEventListener('click', () => this.switchChannel('release')); } + if (nightlyBtn) { + nightlyBtn.addEventListener('click', () => this.switchChannel('nightly')); + } + } + + async switchChannel(channel) { + if (channel === this.channelMode) { + return; + } + if (this.isUpdating) { + return; + } + if (!this.hasGit && channel === 'nightly') { + const confirmed = await this._confirmChannelSwitch( + 'update.channelSwitch.nightlyTitle', + 'update.channelSwitch.nightlyMessage' + ); + if (!confirmed) return; + } + if (this.hasGit && channel === 'release') { + const confirmed = await this._confirmChannelSwitch( + 'update.channelSwitch.releaseTitle', + 'update.channelSwitch.releaseMessage' + ); + if (!confirmed) return; + } + + try { + this.isUpdating = true; + this.showUpdateProgress(true); + this.updateProgress(10, translate('update.channelSwitch.switching', { channel })); + + const response = await fetch('/api/lm/switch-channel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel }) + }); + + const data = await response.json(); + + if (data.success) { + this.channelMode = channel; + await this.checkForUpdates({ force: true }); + this.updateModalContent(); + this.updateChannelUI(); + this._showSwitchCompleteMessage(data.new_version); + this.progressKeepVisible = true; + } else { + throw new Error(data.error || translate('update.channelSwitch.failed')); + } + } catch (error) { + console.error('Channel switch failed:', error); + this.updateProgress(0, translate('update.channelSwitch.failed')); + } finally { + if (this.progressKeepVisible) { + this.isUpdating = false; + this.progressKeepVisible = false; + } else { + setTimeout(() => { + this.showUpdateProgress(false); + this.isUpdating = false; + }, 2000); + } + } + } + + updateChannelUI() { + const releaseBtn = document.getElementById('channelRelease'); + const nightlyBtn = document.getElementById('channelNightly'); + + if (releaseBtn) { + releaseBtn.classList.toggle('active', this.channelMode === 'release'); + } + if (nightlyBtn) { + nightlyBtn.classList.toggle('active', this.channelMode === 'nightly'); + } + } + + async _confirmChannelSwitch(titleKey, messageKey) { + return new Promise((resolve) => { + const title = translate(titleKey); + const message = translate(messageKey); + const cancelText = translate('common.cancel'); + const confirmText = translate('common.confirm'); + + const overlay = document.createElement('div'); + overlay.className = 'channel-switch-overlay'; + overlay.innerHTML = ` +
+

${title}

+

${message}

+
+ + +
+
+ `; + + const dismiss = (result) => { + document.removeEventListener('keydown', onKeydown); + overlay.remove(); + resolve(result); + }; + + const onKeydown = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + dismiss(false); + } + }; + + document.addEventListener('keydown', onKeydown, { capture: true }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + dismiss(false); + } + }); + + overlay.querySelector('.channel-switch-cancel').addEventListener('click', () => { + dismiss(false); + }); + + overlay.querySelector('.channel-switch-confirm').addEventListener('click', () => { + dismiss(true); + }); + + document.body.appendChild(overlay); + }); } setupNotificationCenter() { @@ -373,7 +493,8 @@ export class UpdateService { try { // Call backend API to check for updates with nightly flag - const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`); + const nightly = this.channelMode === 'nightly'; + const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`); const data = await response.json(); if (data.success) { @@ -381,17 +502,19 @@ export class UpdateService { this.latestVersion = data.latest_version || "v0.0.0"; this.updateInfo = data; this.gitInfo = data.git_info || this.gitInfo; - - // Explicitly set update availability based on version comparison - this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion); - - // Update last check time + this.hasGit = data.has_git || false; + if (this.channelMode === null) { + this.channelMode = this.hasGit ? 'nightly' : 'release'; + } + + this.updateAvailable = data.update_available; + this.lastCheckTime = now; setStorageItem('last_update_check', now.toString()); - - // Update UI + this.updateBadgeVisibility(); this.updateModalContent(); + this.updateChannelUI(); console.log("Update check complete:", { currentVersion: this.currentVersion, @@ -482,8 +605,27 @@ export class UpdateService { if (currentVersionEl) currentVersionEl.textContent = this.currentVersion; + const newVersionLabel = modal.querySelector('.new-version .label'); + if (newVersionLabel) { + newVersionLabel.textContent = (this.updateInfo?.nightly) + ? `${translate('update.latestMain')}:` + : `${translate('update.newVersion')}:`; + } + if (newVersionEl) { - newVersionEl.textContent = this.latestVersion; + if (this.updateInfo?.nightly) { + const behind = this.updateInfo.behind_by || 0; + const hash = this.latestVersion.replace('main-', ''); + const date = this.updateInfo.commit_date || ''; + const datePart = date ? ` · ${date}` : ''; + if (behind > 0) { + newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${hash}${datePart})`; + } else { + newVersionEl.textContent = `Up to date (${hash}${datePart})`; + } + } else { + newVersionEl.textContent = this.latestVersion; + } } // Update update button state @@ -599,8 +741,12 @@ export class UpdateService { // Update GitHub link to point to the specific release if available const githubLink = modal.querySelector('.update-link'); if (githubLink && this.latestVersion) { - const versionTag = this.latestVersion.replace(/^v/, ''); - githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`; + if (this.updateInfo?.nightly) { + githubLink.href = 'https://github.com/willmiao/ComfyUI-Lora-Manager/commits/main'; + } else { + const versionTag = this.latestVersion.replace(/^v/, ''); + githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`; + } } } @@ -623,7 +769,7 @@ export class UpdateService { 'Content-Type': 'application/json' }, body: JSON.stringify({ - nightly: this.nightlyMode + nightly: this.channelMode === 'nightly' }) }); @@ -698,7 +844,26 @@ export class UpdateService { progressText.textContent = text; } } - + + _showSwitchCompleteMessage(version) { + this.showUpdateProgress(true); + this.updateProgress(100, ''); + const progressText = document.getElementById('updateProgressText'); + if (progressText) { + progressText.innerHTML = ` +
+ + ${translate('update.completion.successMessage', { version })} +

+
+ ${translate('update.completion.restartMessage')}
+ ${translate('update.completion.reloadMessage')} +
+
+ `; + } + } + showUpdateCompleteMessage(newVersion) { const modal = document.getElementById('updateModal'); if (!modal) return; @@ -771,6 +936,7 @@ export class UpdateService { // Update the modal content immediately with current data this.updateModalContent(); + this.updateChannelUI(); this.renderRecentBanners(); // Show the modal with current data @@ -801,8 +967,8 @@ export class UpdateService { if (data.success) { this.currentVersionInfo = data.version; - - // Check if version matches stored version + this.hasGit = data.has_git || false; + this.versionMismatch = !isVersionMatch(this.currentVersionInfo); if (this.versionMismatch) { diff --git a/templates/components/modals/update_modal.html b/templates/components/modals/update_modal.html index 23760309..63ba4904 100644 --- a/templates/components/modals/update_modal.html +++ b/templates/components/modals/update_modal.html @@ -19,6 +19,20 @@
+ + +
+
{{ t('update.channel') }}
+
+ + +
+
+
diff --git a/tests/routes/test_update_routes.py b/tests/routes/test_update_routes.py index ef7c2fff..e4c6e660 100644 --- a/tests/routes/test_update_routes.py +++ b/tests/routes/test_update_routes.py @@ -1,10 +1,33 @@ import logging +import os +import shutil from aiohttp import ClientError +from aiohttp import web import pytest from py.routes import update_routes +def _fake_request(body=None, query_params=None): + from multidict import MultiDict + + q = MultiDict(query_params or {}) + + req = type("Req", (), { + "has_body": body is not None, + "match_info": {}, + "rel_url": type("U", (), {"query": q})(), + "query": q, + "app": {}, + })() + + async def _json(): + return body or {} + + req.json = _json + return req + + class OfflineDownloader: async def make_request(self, *_, **__): return False, "Cannot connect to host" @@ -53,10 +76,12 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo caplog.set_level(logging.WARNING) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader())) - version, changelog = await update_routes.UpdateRoutes._get_nightly_version() + version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version() assert version == "main" assert changelog == [] + assert behind_by == 0 + assert commit_date == "" assert "Unable to reach GitHub for nightly version" in caplog.text assert "Traceback" not in caplog.text @@ -236,3 +261,240 @@ async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_pa clean_args = clean_calls[0][1] for name in update_routes._PRESERVE_DIRS: assert name in clean_args, f"{name} missing from git clean excludes (stable)" + +def test_init_git_repo_creates_valid_repo(tmp_path, monkeypatch): + if not shutil.which("git"): + pytest.skip("git executable not found") + + plugin_root = tmp_path / "plugin" + plugin_root.mkdir() + (plugin_root / ".tracking").write_text("pyproject.toml") + (plugin_root / "settings.json").write_text('{"some": "value"}') + + try: + success, version = update_routes.UpdateRoutes._init_git_repo(str(plugin_root)) + except Exception as e: + pytest.skip(f"Network unavailable for git fetch: {e}") + + assert success is True + assert version.startswith("main-") + assert len(version) > len("main-") + assert (plugin_root / ".git").is_dir() + assert not (plugin_root / ".tracking").exists() + assert (plugin_root / "settings.json").exists() + assert (plugin_root / "pyproject.toml").exists() + + +@pytest.mark.asyncio +async def test_switch_channel_invalid_channel_returns_error(): + req = _fake_request({"channel": "bad_channel"}) + resp = await update_routes.UpdateRoutes.switch_channel(req) + + data = _raw_body(resp) + assert not data["success"] + assert "Invalid channel" in data["error"] + + +@pytest.mark.asyncio +async def test_switch_channel_to_nightly_without_git_inits_repo(monkeypatch, tmp_path): + routes_file = tmp_path / "py" / "routes" / "update_routes.py" + routes_file.parent.mkdir(parents=True) + routes_file.write_text("") + monkeypatch.setattr(update_routes, "__file__", str(routes_file)) + monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) + monkeypatch.setattr( + update_routes.UpdateRoutes, + "_init_git_repo", + staticmethod(lambda plugin_root: (True, "main-fedcba9")), + ) + + req = _fake_request({"channel": "nightly"}) + resp = await update_routes.UpdateRoutes.switch_channel(req) + data = _raw_body(resp) + + assert data["success"] is True + assert data["channel"] == "nightly" + assert data["new_version"] == "main-fedcba9" + + +@pytest.mark.asyncio +async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch, tmp_path): + routes_file = tmp_path / "py" / "routes" / "update_routes.py" + routes_file.parent.mkdir(parents=True) + routes_file.write_text("") + monkeypatch.setattr(update_routes, "__file__", str(routes_file)) + monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) + + (tmp_path / ".git").mkdir() + + async def _fake_git_update(*args, **kwargs): + return True, "main-1111111" + + monkeypatch.setattr( + update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update + ) + + req = _fake_request({"channel": "nightly"}) + resp = await update_routes.UpdateRoutes.switch_channel(req) + data = _raw_body(resp) + + assert data["success"] is True + assert data["channel"] == "nightly" + assert data["new_version"] == "main-1111111" + + +@pytest.mark.asyncio +async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp_path): + routes_file = tmp_path / "py" / "routes" / "update_routes.py" + routes_file.parent.mkdir(parents=True) + routes_file.write_text("") + monkeypatch.setattr(update_routes, "__file__", str(routes_file)) + monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) + + (tmp_path / ".git").mkdir() + + async def _fake_zip(*args, **kwargs): + return True, "v9.9.9" + + monkeypatch.setattr( + update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip + ) + + req = _fake_request({"channel": "release"}) + resp = await update_routes.UpdateRoutes.switch_channel(req) + data = _raw_body(resp) + + assert data["success"] is True + assert data["channel"] == "release" + assert data["new_version"] == "v9.9.9" + + +@pytest.mark.asyncio +async def test_switch_channel_to_release_without_git_still_downloads_zip(monkeypatch, tmp_path): + routes_file = tmp_path / "py" / "routes" / "update_routes.py" + routes_file.parent.mkdir(parents=True) + routes_file.write_text("") + monkeypatch.setattr(update_routes, "__file__", str(routes_file)) + monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) + + async def _fake_zip(*args, **kwargs): + return True, "v2.0.0" + + monkeypatch.setattr( + update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip + ) + + req = _fake_request({"channel": "release"}) + resp = await update_routes.UpdateRoutes.switch_channel(req) + data = _raw_body(resp) + + assert data["success"] is True + assert data["channel"] == "release" + assert data["new_version"] == "v2.0.0" + + +class _NightlyDownloader: + """Returns a fake main-branch commit AND a compare response.""" + + commit_sha = "7777777" + commit_msg = "test: add nightly feature" + commit_date = "2026-07-27T12:00:00Z" + behind_by = 5 + + async def make_request(self, method, url, **kwargs): + if "/compare/" in url: + return True, {"behind_by": self.behind_by} + return True, { + "sha": self.commit_sha, + "commit": { + "message": self.commit_msg, + "committer": {"date": self.commit_date}, + }, + } + + +@pytest.mark.asyncio +async def test_get_nightly_version_parses_behind_by(monkeypatch): + monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_NightlyDownloader())) + + version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version( + local_hash="abc1234" + ) + + assert version == "main-7777777" + assert behind_by == 5 + assert commit_date == "2026-07-27" + assert len(changelog) == 1 + assert changelog[0] == "test: add nightly feature" + + +class _CheckUpdatesDownloader: + """Fake downloader returning both a release list and a nightly commit + compare.""" + + commit_sha = "8888888" + commit_date = "2026-07-28T00:00:00Z" + + async def make_request(self, method, url, **kwargs): + if "/releases" in url: + return True, [ + { + "tag_name": "v3.0.0", + "body": "- Feature A\n- Feature B", + "published_at": "2026-07-20T00:00:00Z", + } + ] + if "/compare/" in url: + return True, {"behind_by": 3} + return True, { + "sha": self.commit_sha + "0" * 33, + "commit": { + "message": "latest commit", + "committer": {"date": self.commit_date}, + }, + } + + +@pytest.mark.asyncio +async def test_check_updates_nightly_response_includes_behind_and_date(monkeypatch, tmp_path): + monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_CheckUpdatesDownloader())) + + monkeypatch.setattr( + update_routes.UpdateRoutes, + "_get_local_version", + staticmethod(lambda: "v1.0.0"), + ) + monkeypatch.setattr( + update_routes.UpdateRoutes, + "_get_git_info", + staticmethod(lambda: { + "commit_hash": "abc1234", + "short_hash": "abc1234", + "branch": "main", + "commit_date": "2026-01-01", + }), + ) + + routes_file = tmp_path / "py" / "routes" / "update_routes.py" + routes_file.parent.mkdir(parents=True) + routes_file.write_text("") + monkeypatch.setattr(update_routes, "__file__", str(routes_file)) + (tmp_path / ".git").mkdir() + + req = _fake_request(query_params={"nightly": "true"}) + resp = await update_routes.UpdateRoutes.check_updates(req) + data = _raw_body(resp) + + assert data["success"] is True + assert data["nightly"] is True + assert data["has_git"] is True + assert data["behind_by"] == 3 + assert data["commit_date"] == "2026-07-28" + assert data["latest_version"] == "main-8888888" + assert isinstance(data["releases"], list) + assert len(data["releases"]) == 1 + assert data["releases"][0]["version"] == "v3.0.0" + + +def _raw_body(response): + import json + return json.loads(response._body.decode())