Files
ComfyUI-Lora-Manager/tests/frontend/managers/BannerService.test.js
T
Will Miao 84146b62fd feat(other-models): announce the feature only when folders are available
Other Models management is opt-in and its folders come from
folder_paths.get_folder_paths(). In plugin mode ComfyUI registers vae,
upscale_models, text_encoders, clip_vision and controlnet out of the box, so
enabling the feature works immediately. Standalone only knows the keys present
in settings.json.folder_paths, and that file is edited by hand - there is no UI
for those keys - so a standalone user who followed the announcement banner
reached "Enable Other Models" and then an empty page.

Gate the announcement on the capability instead of on how the process was
started:

- Config.get_other_models_availability() probes every canonical other key
  (legacy clip collapses into text_encoders where the host exposes
  map_legacy) and reports which sub_types resolve to a folder that exists on
  disk. It deliberately ignores enable_other_models: the question is "could
  this work here at all?". An empty folder counts, because CivitAI downloads
  can target it.
- /api/lm/settings exposes it as the derived, non-persisted
  other_models_paths_available flag; a probe failure yields null and the
  banner fails open.
- BannerService only registers the announcement when the flag is not false.
  `=== false` (not falsy) keeps a cached/older payload working, and nothing is
  written to dismissed_banners, so the banner can return once folders exist.
- The Other page grows an "enabled but nothing to scan" empty state driven by
  config.other_roots, showing the settings.json snippet for standalone and a
  pointer to ComfyUI model paths otherwise, plus an Open Settings action. It
  also covers the corner where only a non-default sub_type has a folder.

Translate the six other.noPaths.* keys into all nine locales and record the
new "folder key" / "on disk" terminology in the i18n guidelines.

Backend tests and pytest tests/i18n could not run in this environment (no
pytest/platformdirs); the probe was exercised against a stubbed folder_paths.
Frontend: 120 files / 1101 JS tests passed.
2026-09-13 21:47:16 +08:00

410 lines
15 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import * as storageHelpers from '../../../static/js/utils/storageHelpers.js';
import * as i18nHelpers from '../../../static/js/utils/i18nHelpers.js';
import { state } from '../../../static/js/state/index.js';
// Mock storage helpers
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn(),
removeStorageItem: vi.fn()
}));
// Mock i18n helpers
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, params, defaultValue) => defaultValue || key)
}));
// Mock state
vi.mock('../../../static/js/state/index.js', () => ({
state: {
global: {
settings: {
language: 'en'
}
}
}
}));
// Mock the shared Other Models helpers (exercised by their own tests)
vi.mock('../../../static/js/utils/otherModels.js', () => ({
enableOtherModels: vi.fn().mockResolvedValue(),
openOtherModelsSettings: vi.fn(),
}));
import { enableOtherModels, openOtherModelsSettings } from '../../../static/js/utils/otherModels.js';
describe('BannerService', () => {
beforeEach(() => {
// Clear all mocks
vi.clearAllMocks();
// Reset banner service state
bannerService.banners.clear();
bannerService.initialized = false;
bannerService.recentHistory = []; // Clear history for each test
// Clear DOM
document.body.innerHTML = '<div id="banner-container"></div>';
});
describe('Community Support Banner', () => {
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
beforeEach(() => {
// Mock the version check to avoid resetting state
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2'; // Current version
}
return defaultValue;
});
// Initialize the banner service
bannerService.initializeCommunitySupportState();
});
it('should not show community support banner before 5 days have passed', () => {
const now = Date.now();
const firstSeenAt = now - (3 * 24 * 60 * 60 * 1000); // 3 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should not be registered yet
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false);
} finally {
global.Date.now = originalNow;
}
});
it('should show community support banner after 5 days have passed', () => {
const now = Date.now();
const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should be registered
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(true);
} finally {
global.Date.now = originalNow;
}
});
it('should not show community support banner if it has been dismissed', () => {
const now = Date.now();
const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return firstSeenAt;
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [COMMUNITY_SUPPORT_BANNER_ID]; // Dismissed
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Banner should not be registered because it's dismissed
expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false);
} finally {
global.Date.now = originalNow;
}
});
it('should set first seen time if not already set', () => {
const now = Date.now();
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) {
return null; // Not set
}
if (key === COMMUNITY_SUPPORT_VERSION_KEY) {
return 'v2';
}
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
// Mock Date.now to control time
const originalNow = Date.now;
global.Date.now = vi.fn(() => now);
try {
bannerService.prepareCommunitySupportBanner();
// Should have set the first seen time
expect(storageHelpers.setStorageItem).toHaveBeenCalledWith(
COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY,
now
);
} finally {
global.Date.now = originalNow;
}
});
});
describe('Other Models announcement', () => {
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const prepareBanner = (dismissed = []) => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissed;
}
return defaultValue;
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
bannerService.prepareOtherModelsBanner();
};
const bannerElement = () =>
document.querySelector(`[data-banner-id="${OTHER_MODELS_BANNER_ID}"]`);
beforeEach(() => {
state.global.settings.enable_other_models = false;
state.global.settings.other_models_paths_available = true;
});
it('announces the feature while it is switched off', () => {
prepareBanner();
const element = bannerElement();
expect(element).not.toBeNull();
expect(element.querySelector('.banner-title').textContent)
.toContain('Other Models Management is available');
});
it('stays silent when the host exposes no other-model folders', () => {
// Standalone installs without the folder_paths keys in
// settings.json would land on an empty page, so do not announce.
state.global.settings.other_models_paths_available = false;
prepareBanner();
expect(bannerElement()).toBeNull();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
});
it('still announces when availability is unknown (older payload)', () => {
delete state.global.settings.other_models_paths_available;
prepareBanner();
expect(bannerElement()).not.toBeNull();
});
it('stays silent once the feature is enabled', () => {
state.global.settings.enable_other_models = true;
prepareBanner();
expect(bannerElement()).toBeNull();
});
it('stays silent when it was dismissed before', () => {
prepareBanner([OTHER_MODELS_BANNER_ID]);
expect(bannerElement()).toBeNull();
});
it('enables the feature from the primary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="enable-other-models"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(enableOtherModels).toHaveBeenCalledTimes(1);
});
it('opens the settings section from the secondary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('can drop the announcement without dismissing it', () => {
prepareBanner();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(true);
bannerService.removeOtherModelsAnnouncement();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
expect(storageHelpers.setStorageItem).not.toHaveBeenCalledWith(
'dismissed_banners',
expect.arrayContaining([OTHER_MODELS_BANNER_ID])
);
});
});
describe('Banner Dismissal', () => {
it('should add banner to dismissed_banners array when dismissed', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return [];
}
return defaultValue;
});
bannerService.dismissBanner('test-banner');
expect(storageHelpers.setStorageItem).toHaveBeenCalledWith(
'dismissed_banners',
['test-banner']
);
});
it('should not add duplicate banner IDs to dismissed_banners array', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return ['test-banner'];
}
return defaultValue;
});
bannerService.dismissBanner('test-banner');
// Should not have been called again since it's already dismissed
expect(storageHelpers.setStorageItem).not.toHaveBeenCalled();
});
});
describe('Banner History', () => {
const testBanner = {
id: 'test-banner',
title: 'Test Banner',
content: 'This is a test banner',
actions: [
{
text: 'Action 1',
icon: 'fas fa-check',
url: 'https://example.com',
type: 'primary'
}
]
};
it('should add banner to history when first recorded', () => {
// Mock storage to return empty array
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'banner_history') {
return [];
}
return defaultValue;
});
// Record the banner appearance
bannerService.recordBannerAppearance(testBanner);
// Should have added the banner to history
expect(bannerService.recentHistory).toHaveLength(1);
expect(bannerService.recentHistory[0].id).toBe('test-banner');
expect(bannerService.recentHistory[0].title).toBe('Test Banner');
});
it('should not add duplicate banner to history when recorded multiple times', () => {
// Mock storage to return empty array
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'banner_history') {
return [];
}
return defaultValue;
});
// Record the same banner twice
bannerService.recordBannerAppearance(testBanner);
bannerService.recordBannerAppearance(testBanner);
// Should only have one entry in history
expect(bannerService.recentHistory).toHaveLength(1);
expect(bannerService.recentHistory[0].id).toBe('test-banner');
});
it('should add different banners to history', () => {
// Mock storage to return empty array
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'banner_history') {
return [];
}
return defaultValue;
});
const anotherBanner = {
id: 'another-banner',
title: 'Another Banner',
content: 'This is another test banner'
};
// Record two different banners
bannerService.recordBannerAppearance(testBanner);
bannerService.recordBannerAppearance(anotherBanner);
// Should have two entries in history
expect(bannerService.recentHistory).toHaveLength(2);
expect(bannerService.recentHistory[0].id).toBe('another-banner');
expect(bannerService.recentHistory[1].id).toBe('test-banner');
});
});
});