Compare commits

...

5 Commits

Author SHA1 Message Date
Will Miao
2228627ff4 chore(release): bump version to v1.2.0 2026-07-31 21:25:38 +08:00
Will Miao
4c647ad9c8 fix(update): throttle nightly update badge to once per day 2026-07-31 21:18:58 +08:00
Will Miao
8ca3e6c33f fix(ui): guard marquee bulk-mode entry against click jitter and stale drag state 2026-07-31 18:40:14 +08:00
Will Miao
dd6bdbf297 fix(update): persist update_channel via settings.json instead of hasGit
After b464fdc3 (preserve .git on release switch), the hasGit-based
channel detection is unreliable — .git now exists for both release
and nightly installs, so page refresh always reset the channel.

- Add _resolveChannelFromSettings() with migration heuristic:
  !hasGit → release (ZIP), detached HEAD → release (on tag),
  on branch → nightly. Uses gitInfo.branch from check-updates.
- Persist resolved channel to settings.json on first load
  (one-time migration) and on explicit switchChannel.
- Add update_channel validation (release|nightly) in backend
  update_settings handler.
- Remove hasGit-based guessing from initialize(); defer to
  checkForUpdates where full gitInfo is available.
- Channel resolution runs before checkForUpdates early-returns
  to avoid null channelMode on reload-within-interval.

Tests: 361 passed.
2026-07-31 13:23:54 +08:00
Will Miao
b47dde87e4 fix(settings): suppress error toasts when optional model roots are empty 2026-07-31 10:07:52 +08:00
9 changed files with 905 additions and 325 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1562,6 +1562,11 @@ class SettingsHandler:
{"success": False, "error": validation_error}
)
if key == "update_channel" and value not in ("release", "nightly"):
return web.json_response(
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
)
if value == "__DELETE__" and key in (
"proxy_username",
"proxy_password",

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.9"
version = "1.2.0"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -27,6 +27,8 @@ export class BulkManager {
// Drag detection properties
this.dragThreshold = 5; // Pixels to move before considering it a drag
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 };
@@ -173,6 +175,19 @@ export class BulkManager {
});
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
// Only track marquee/drag while the left button is physically held.
// mouseup can be missed (release outside the window, focus loss, driver quirks),
// so mousemove must verify the button state itself instead of relying on it.
if (!(e.buttons & 1)) {
if (this.isMarqueeActive) {
this.endMarqueeSelection(e);
} else {
this.mouseDownTime = 0;
this.isDragging = false;
}
return false;
}
if (this.isMarqueeActive) {
this.lastClientX = e.clientX;
this.lastClientY = e.clientY;
@@ -184,7 +199,10 @@ export class BulkManager {
const dy = e.clientY - this.mouseDownPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= this.dragThreshold) {
// Require both enough movement AND enough hold time so quick
// click jitter from micro-movement input devices is not a marquee.
const heldTime = Date.now() - this.mouseDownTime;
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
this.isDragging = true;
this.startMarqueeSelection(e, true);
}
@@ -1958,9 +1976,31 @@ export class BulkManager {
// Remove visual feedback class
document.body.classList.remove('marquee-selecting');
// Compute the actual drag box size in document coordinates, matching how
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
// large document-space box) as accidental clicks.
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
// Get selection count
const selectionCount = state.selectedModels.size;
// A tiny box (e.g. click jitter that happened to graze a card) is treated
// as an accidental click: undo any selection and leave bulk mode.
if (isTinyMarquee) {
this.clearSelection();
if (state.bulkMode) {
this.toggleBulkMode();
}
this.initialSelectedModels.clear();
return;
}
// If no models were selected, exit bulk mode
if (selectionCount === 0) {
if (state.bulkMode) {

View File

@@ -1517,11 +1517,20 @@ export class SettingsManager {
return data;
}
async loadLoraRoots() {
try {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
showNoRootsPlaceholder(select) {
select.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
select.appendChild(option);
select.disabled = true;
}
async loadLoraRoots() {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
try {
// Fetch lora roots
const response = await fetch('/api/lm/loras/roots');
if (!response.ok) {
@@ -1530,10 +1539,12 @@ export class SettingsManager {
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No LoRA roots found');
this.showNoRootsPlaceholder(defaultLoraRootSelect);
return;
}
defaultLoraRootSelect.innerHTML = '';
defaultLoraRootSelect.disabled = false;
// Add options for each root
data.roots.forEach(root => {
@@ -1548,15 +1559,16 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading LoRA roots:', error);
this.showNoRootsPlaceholder(defaultLoraRootSelect);
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
}
}
async loadCheckpointRoots() {
try {
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
if (!defaultCheckpointRootSelect) return;
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
if (!defaultCheckpointRootSelect) return;
try {
// Fetch checkpoint roots (checkpoint paths only, not unet)
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
if (!response.ok) {
@@ -1565,10 +1577,12 @@ export class SettingsManager {
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No checkpoint roots found');
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
return;
}
defaultCheckpointRootSelect.innerHTML = '';
defaultCheckpointRootSelect.disabled = false;
// Add options for each root
data.roots.forEach(root => {
@@ -1583,15 +1597,16 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading checkpoint roots:', error);
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
}
}
async loadUnetRoots() {
try {
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
if (!defaultUnetRootSelect) return;
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
if (!defaultUnetRootSelect) return;
try {
// Fetch unet roots (diffusion model paths only)
const response = await fetch('/api/lm/checkpoints/unet_roots');
if (!response.ok) {
@@ -1600,10 +1615,12 @@ export class SettingsManager {
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No diffusion model roots found');
this.showNoRootsPlaceholder(defaultUnetRootSelect);
return;
}
defaultUnetRootSelect.innerHTML = '';
defaultUnetRootSelect.disabled = false;
// Add options for each root
data.roots.forEach(root => {
@@ -1618,15 +1635,16 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading diffusion model roots:', error);
this.showNoRootsPlaceholder(defaultUnetRootSelect);
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
}
}
async loadEmbeddingRoots() {
try {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
try {
// Fetch embedding roots
const response = await fetch('/api/lm/embeddings/roots');
if (!response.ok) {
@@ -1635,10 +1653,12 @@ export class SettingsManager {
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No embedding roots found');
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
return;
}
defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.disabled = false;
// Add options for each root
data.roots.forEach(root => {
@@ -1653,6 +1673,7 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading embedding roots:', error);
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
}
}

View File

@@ -1,11 +1,12 @@
import { modalManager } from './ModalManager.js';
import {
getStorageItem,
setStorageItem,
getStoredVersionInfo,
import {
getStorageItem,
setStorageItem,
getStoredVersionInfo,
setStoredVersionInfo,
isVersionMatch
} from '../utils/storageHelpers.js';
import { state } from '../state/index.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -26,6 +27,8 @@ export class UpdateService {
this.isUpdating = false;
this.channelMode = null;
this.hasGit = false;
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
this.nightlyBadgeShown = false;
this.progressKeepVisible = false;
this.currentVersionInfo = null;
this.versionMismatch = false;
@@ -59,9 +62,6 @@ export class UpdateService {
// Perform update check if needed
this.checkVersionInfo().then(() => {
if (this.channelMode === null) {
this.channelMode = this.hasGit ? 'nightly' : 'release';
}
this.checkForUpdates().then(() => {
this.updateBadgeVisibility();
});
@@ -118,6 +118,14 @@ export class UpdateService {
if (data.success) {
this.channelMode = channel;
// Persist channel preference to settings.json
fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ update_channel: channel })
}).then(r => {
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
}).catch(e => console.warn('Failed to persist update channel:', e));
await this.checkForUpdates({ force: true });
this.updateModalContent();
this.updateChannelUI();
@@ -154,6 +162,20 @@ export class UpdateService {
}
}
_resolveChannelFromSettings() {
const stored = state?.global?.settings?.update_channel;
if (stored === 'nightly' || stored === 'release') {
return stored;
}
if (!this.hasGit) {
return 'release';
}
if (this.gitInfo?.branch === 'detached') {
return 'release';
}
return 'nightly';
}
async _confirmChannelSwitch(titleKey, messageKey) {
return new Promise((resolve) => {
const title = translate(titleKey);
@@ -475,6 +497,18 @@ export class UpdateService {
}
async checkForUpdates({ force = false } = {}) {
let needsMigration = false;
if (this.channelMode === null) {
const stored = state?.global?.settings?.update_channel;
if (stored === 'nightly' || stored === 'release') {
this.channelMode = stored;
} else if (!this.hasGit) {
this.channelMode = 'release';
needsMigration = true;
}
// hasGit=true with no stored value: wait for gitInfo.branch
}
if (!force && !this.updateNotificationsEnabled) {
return;
}
@@ -493,7 +527,7 @@ export class UpdateService {
try {
// Call backend API to check for updates with nightly flag
const nightly = this.channelMode === 'nightly';
const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
const data = await response.json();
@@ -503,12 +537,28 @@ export class UpdateService {
this.updateInfo = data;
this.gitInfo = data.git_info || this.gitInfo;
this.hasGit = data.has_git || false;
if (this.channelMode === null) {
this.channelMode = this.hasGit ? 'nightly' : 'release';
if (needsMigration || this.channelMode === null) {
this.channelMode = this._resolveChannelFromSettings();
if (state?.global?.settings) {
state.global.settings.update_channel = this.channelMode;
}
fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ update_channel: this.channelMode })
}).then(r => {
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
}).catch(e => console.warn('Failed to persist update channel:', e));
}
this.updateAvailable = data.update_available;
// Nightly channel: surface the update badge at most once per calendar day.
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
this._markNightlyNotified();
}
this.lastCheckTime = now;
setStorageItem('last_update_check', now.toString());
@@ -558,6 +608,28 @@ export class UpdateService {
return false;
}
_getTodayKey() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${month}-${day}`;
}
_isNightlyBadgeAllowed() {
if (this.channelMode !== 'nightly') {
return true;
}
// Keep the badge visible for the rest of the session once shown, but do
// not show it again on later sessions within the same calendar day.
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
}
_markNightlyNotified() {
this.nightlyNotifyDate = this._getTodayKey();
this.nightlyBadgeShown = true;
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
}
updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle');
@@ -566,9 +638,12 @@ export class UpdateService {
? bannerService.getUnreadBannerCount()
: 0;
// Force updating badges visibility based on current state
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
if (updateToggle) {
let tooltipKey = 'header.actions.notifications';
if (this.updateNotificationsEnabled && this.updateAvailable) {
if (shouldShowUpdate) {
tooltipKey = 'update.updateAvailable';
} else if (unreadBanners > 0) {
tooltipKey = 'update.tabs.messages';
@@ -576,8 +651,6 @@ export class UpdateService {
updateToggle.title = translate(tooltipKey);
}
// Force updating badges visibility based on current state
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const shouldShow = shouldShowUpdate || unreadBanners > 0;
if (updateBadge) {

View File

@@ -0,0 +1,186 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { state } from '../../../static/js/state/index.js';
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
import { eventManager } from '../../../static/js/utils/EventManager.js';
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
function fire(type, init = {}) {
return new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
}
describe('BulkManager marquee guards', () => {
beforeEach(() => {
vi.useFakeTimers();
// jsdom may not provide requestAnimationFrame; stub it so the auto-scroll loop is a no-op.
window.requestAnimationFrame = vi.fn();
window.cancelAnimationFrame = vi.fn();
eventManager.cleanup();
state.currentPageType = MODEL_TYPES.LORA;
state.bulkMode = false;
state.selectedModels.clear();
document.body.innerHTML = '<div class="page-content"></div>';
const pageContent = document.querySelector('.page-content');
pageContent.getBoundingClientRect = () => ({
top: 0,
left: 0,
right: 1000,
bottom: 1000,
width: 1000,
height: 1000,
x: 0,
y: 0,
toJSON: () => ({}),
});
pageContent.scrollBy = vi.fn();
});
afterEach(() => {
eventManager.cleanup();
vi.useRealTimers();
document.body.innerHTML = '';
});
function createBulkManager() {
const bulk = new BulkManager();
bulk.initialize();
return bulk;
}
it('never starts a marquee when the left button is not held', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
expect(bulk.mouseDownTime).toBe(0);
expect(bulk.isMarqueeActive).toBe(false);
expect(state.bulkMode).toBe(false);
expect(document.querySelector('.marquee-selection')).toBeNull();
});
it('requires holding the left button for the drag delay before starting a marquee', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
// Fast movement: far enough, but too soon after mousedown.
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 30, clientY: 10 }));
expect(state.bulkMode).toBe(false);
expect(bulk.isMarqueeActive).toBe(false);
// Once the hold time has elapsed, the same drag qualifies.
vi.advanceTimersByTime(100);
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 35, clientY: 12 }));
expect(state.bulkMode).toBe(true);
expect(bulk.isMarqueeActive).toBe(true);
expect(document.querySelector('.marquee-selection')).not.toBeNull();
});
it('ends an active marquee if the left button is released without a mouseup event', () => {
const bulk = createBulkManager();
bulk.mouseDownPosition = { x: 10, y: 10 };
bulk.startMarqueeSelection({}, true);
expect(state.bulkMode).toBe(true);
expect(document.querySelector('.marquee-selection')).not.toBeNull();
// No mouseup was dispatched; a plain move with the button released finalizes it.
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
expect(bulk.isMarqueeActive).toBe(false);
expect(document.querySelector('.marquee-selection')).toBeNull();
expect(state.bulkMode).toBe(false); // zero selected -> auto-exit
});
it('treats a tiny marquee as an accidental click: clears selection and exits bulk mode', () => {
const bulk = createBulkManager();
const card = document.createElement('div');
card.className = 'model-card selected';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
state.selectedModels.add('/models/test.safetensors');
bulk.mouseDownPosition = { x: 100, y: 100 };
bulk.startMarqueeSelection({}, true);
expect(state.bulkMode).toBe(true);
bulk.endMarqueeSelection({ clientX: 103, clientY: 104 });
expect(state.bulkMode).toBe(false);
expect(state.selectedModels.size).toBe(0);
expect(card.classList.contains('selected')).toBe(false);
});
it('keeps selection and bulk mode when the marquee is large enough', () => {
const bulk = createBulkManager();
const card = document.createElement('div');
card.className = 'model-card selected';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
state.selectedModels.add('/models/test.safetensors');
bulk.mouseDownPosition = { x: 100, y: 100 };
bulk.startMarqueeSelection({}, true);
bulk.endMarqueeSelection({ clientX: 130, clientY: 140 });
expect(state.bulkMode).toBe(true);
expect(state.selectedModels.has('/models/test.safetensors')).toBe(true);
expect(card.classList.contains('selected')).toBe(true);
});
it('keeps auto-scroll marquee selections when the pointer only moved a few pixels', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
// Card just below the press point in document coordinates.
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/off-screen.safetensors';
card.getBoundingClientRect = () => ({
top: 950,
left: 400,
right: 600,
bottom: 1050,
width: 200,
height: 100,
x: 400,
y: 950,
toJSON: () => ({}),
});
document.body.appendChild(card);
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 500, clientY: 900 }));
vi.advanceTimersByTime(100);
// Small pointer move: enough to start the marquee, but under minMarqueeSize.
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
expect(bulk.isMarqueeActive).toBe(true);
// Auto-scroll grows the document-space box while the pointer stays nearly still.
pageContent.scrollTop = 200;
card.getBoundingClientRect = () => ({
top: 750,
left: 400,
right: 600,
bottom: 850,
width: 200,
height: 100,
x: 400,
y: 750,
toJSON: () => ({}),
});
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
// Release: the client-space box is tiny, but the document-space box is not.
document.dispatchEvent(fire('mouseup', { button: 0, clientX: 506, clientY: 906 }));
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
expect(state.bulkMode).toBe(true);
});
});

View File

@@ -106,6 +106,118 @@ afterEach(() => {
});
});
describe('SettingsManager root selects', () => {
const rootCases = [
{
method: 'loadLoraRoots',
selectId: 'defaultLoraRoot',
endpoint: '/api/lm/loras/roots',
errorKey: 'toast.settings.loraRootsFailed',
},
{
method: 'loadCheckpointRoots',
selectId: 'defaultCheckpointRoot',
endpoint: '/api/lm/checkpoints/checkpoints_roots',
errorKey: 'toast.settings.checkpointRootsFailed',
},
{
method: 'loadUnetRoots',
selectId: 'defaultUnetRoot',
endpoint: '/api/lm/checkpoints/unet_roots',
errorKey: 'toast.settings.unetRootsFailed',
},
{
method: 'loadEmbeddingRoots',
selectId: 'defaultEmbeddingRoot',
endpoint: '/api/lm/embeddings/roots',
errorKey: 'toast.settings.embeddingRootsFailed',
},
];
const appendRootSelect = (id) => {
const select = document.createElement('select');
select.id = id;
document.body.appendChild(select);
return select;
};
it.each(rootCases)(
'populates the $method select with roots and keeps it enabled',
async ({ method, selectId, endpoint }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
select.disabled = true;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots: ['/models/root-a', '/models/root-b'],
}),
});
await manager[method]();
expect(global.fetch).toHaveBeenCalledWith(endpoint);
expect(Array.from(select.options).map(option => option.value)).toEqual([
'/models/root-a',
'/models/root-b',
]);
expect(select.disabled).toBe(false);
expect(showToast).not.toHaveBeenCalled();
}
);
it.each(rootCases)(
'shows a placeholder and no error toast when $method has empty roots',
async ({ method, selectId, endpoint }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots: [],
}),
});
await manager[method]();
expect(global.fetch).toHaveBeenCalledWith(endpoint);
expect(select.options).toHaveLength(1);
expect(select.options[0].value).toBe('');
expect(select.options[0].textContent).toBe('No Default');
expect(select.disabled).toBe(true);
expect(showToast).not.toHaveBeenCalled();
}
);
it.each(rootCases)(
'shows an error toast when the $method roots request fails',
async ({ method, selectId, errorKey }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await manager[method]();
expect(select.options).toHaveLength(1);
expect(select.options[0].value).toBe('');
expect(select.disabled).toBe(true);
expect(showToast).toHaveBeenCalledWith(
errorKey,
expect.objectContaining({ message: expect.any(String) }),
'error',
);
}
);
});
describe('SettingsManager library controls', () => {
it('loads libraries and populates the select', async () => {
const manager = createManager();

View File

@@ -1,12 +1,26 @@
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
import { state } from '../../../static/js/state/index.js';
function createFetchResponse(payload) {
return {
json: vi.fn().mockResolvedValue(payload)
json: vi.fn().mockResolvedValue(payload),
ok: true,
};
}
function stubSettingsUpdateChannel(channel) {
state.global = state.global || {};
state.global.settings = state.global.settings || {};
state.global.settings.update_channel = channel;
}
function clearSettingsUpdateChannel() {
if (state.global?.settings) {
delete state.global.settings.update_channel;
}
}
describe('UpdateService passive checks', () => {
let service;
let fetchMock;
@@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => {
success: true,
current_version: 'v1.0.0',
latest_version: 'v1.0.0',
git_info: { short_hash: 'abc123' }
git_info: { short_hash: 'abc123' },
has_git: true,
}));
global.fetch = fetchMock;
stubSettingsUpdateChannel('release');
service = new UpdateService();
service.updateNotificationsEnabled = false;
service.lastCheckTime = 0;
@@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => {
afterEach(() => {
delete global.fetch;
clearSettingsUpdateChannel();
});
it('skips passive update checks when notifications are disabled', async () => {
@@ -43,3 +61,106 @@ describe('UpdateService passive checks', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
});
});
describe('UpdateService nightly notification throttling', () => {
let fetchMock;
let updateToggle;
let updateBadge;
function stubUpdateBadgeDom() {
updateToggle = document.createElement('div');
updateToggle.className = 'update-toggle';
updateBadge = document.createElement('span');
updateBadge.className = 'update-badge';
updateToggle.appendChild(updateBadge);
document.body.appendChild(updateToggle);
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
if (selector === '.update-toggle') return updateToggle;
if (selector === '.update-toggle .update-badge') return updateBadge;
return null;
});
}
function makeUpdateResponse(channel) {
return {
success: true,
current_version: 'v1.0.0',
latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0',
update_available: true,
git_info: { short_hash: 'abc123' },
has_git: true,
nightly: channel === 'nightly',
changelog: ['test: change'],
releases: [],
behind_by: 3,
commit_date: '2026-07-31',
};
}
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
global.fetch = fetchMock;
stubUpdateBadgeDom();
});
afterEach(() => {
vi.restoreAllMocks();
delete global.fetch;
});
it('shows the nightly badge once and keeps it visible for the session', async () => {
stubSettingsUpdateChannel('nightly');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
const service = new UpdateService();
service.updateNotificationsEnabled = true;
await service.checkForUpdates({ force: true });
expect(service.updateAvailable).toBe(true);
expect(service.nightlyBadgeShown).toBe(true);
expect(service.nightlyNotifyDate).toBe(service._getTodayKey());
expect(updateBadge.classList.contains('visible')).toBe(true);
// A repeated check within the same session keeps the badge visible.
await service.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
});
it('suppresses the nightly badge on a later session in the same day', async () => {
stubSettingsUpdateChannel('nightly');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
const firstService = new UpdateService();
firstService.updateNotificationsEnabled = true;
await firstService.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
// Simulate a fresh page session on the same calendar day.
const secondService = new UpdateService();
secondService.updateNotificationsEnabled = true;
await secondService.checkForUpdates({ force: true });
expect(secondService.updateAvailable).toBe(true);
expect(secondService.nightlyBadgeShown).toBe(false);
expect(updateBadge.classList.contains('visible')).toBe(false);
});
it('is not affected by the daily limit on the release channel', async () => {
stubSettingsUpdateChannel('release');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
const firstService = new UpdateService();
firstService.updateNotificationsEnabled = true;
await firstService.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
const secondService = new UpdateService();
secondService.updateNotificationsEnabled = true;
await secondService.checkForUpdates({ force: true });
expect(secondService.updateAvailable).toBe(true);
expect(updateBadge.classList.contains('visible')).toBe(true);
});
});