Merge pull request #470 from willmiao/codex/add-tests-for-storagehelpers-and-appcore

test: add vitest coverage for storage helpers and core
This commit is contained in:
pixelpaws
2025-09-24 05:21:02 +08:00
committed by GitHub
3 changed files with 423 additions and 4 deletions

View File

@@ -7,17 +7,17 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
| Phase | Goal | Primary Focus | Status | Notes | | Phase | Goal | Primary Focus | Status | Notes |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed | | Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | 🟡 In Progress | Prioritize deterministic scenarios for `storageHelpers`, `AppState` derivatives, and regression cases for migrations | | Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ⚪ Not Started | Requires DOM fixtures from `templates/*.html` and stubbing of manager modules | | Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | 🟡 In Progress | AppCore initialization specs landed; expand to additional page wiring and scroll hooks |
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ⚪ Not Started | Consider shared helpers for mocking API modules and storage | | Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ⚪ Not Started | Consider shared helpers for mocking API modules and storage |
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots | | Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting | | Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting |
## Next Steps Checklist ## Next Steps Checklist
- [ ] Expand unit tests for `storageHelpers` covering migrations and namespace behavior. - [x] Expand unit tests for `storageHelpers` covering migrations and namespace behavior.
- [ ] Document DOM fixture strategy for reproducing template structures in tests. - [ ] Document DOM fixture strategy for reproducing template structures in tests.
- [ ] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies. - [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies.
- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs). - [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress. Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress.

277
static/js/core.test.js Normal file
View File

@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const migrateStorageItemsMock = vi.fn();
const initializeInfiniteScrollMock = vi.fn();
const initThemeMock = vi.fn();
const initBackToTopMock = vi.fn();
const initializeEventManagementMock = vi.fn();
const createPageContextMenuMock = vi.fn().mockReturnValue('page-menu');
const createGlobalContextMenuMock = vi.fn().mockReturnValue('global-menu');
const bulkManagerInitializeMock = vi.fn();
const setBulkContextMenuMock = vi.fn();
const helpManagerInitializeMock = vi.fn();
const updateServiceInitializeMock = vi.fn();
const bannerServiceInitializeMock = vi.fn();
const isBannerVisibleMock = vi.fn().mockReturnValue(false);
const onboardingStartMock = vi.fn();
const settingsWaitMock = vi.fn().mockResolvedValue();
const i18nWaitMock = vi.fn().mockResolvedValue();
const i18nLocaleMock = vi.fn().mockReturnValue('en');
const mockState = {
currentPageType: 'loras',
global: {
settings: {
card_info_display: 'hover'
}
}
};
const mockModalManager = { initialize: vi.fn() };
const mockBulkManager = {
initialize: bulkManagerInitializeMock,
setBulkContextMenu: setBulkContextMenuMock
};
const mockHelpManager = { initialize: helpManagerInitializeMock };
const mockUpdateService = { initialize: updateServiceInitializeMock };
const mockBannerService = {
initialize: bannerServiceInitializeMock,
isBannerVisible: isBannerVisibleMock
};
const mockOnboardingManager = { start: onboardingStartMock };
const mockSettingsManager = { waitForInitialization: settingsWaitMock };
const mockMoveManager = {};
const mockI18n = {
waitForReady: i18nWaitMock,
getCurrentLocale: i18nLocaleMock
};
const loadingManagerInstances = [];
const HeaderManagerInstances = [];
const bulkContextMenuInstances = [];
const exampleImagesManagerInitializeMock = vi.fn();
const LoadingManagerMock = vi.fn(() => {
const instance = { id: Symbol('LoadingManager') };
loadingManagerInstances.push(instance);
return instance;
});
const HeaderManagerMock = vi.fn(() => {
const instance = { id: Symbol('HeaderManager') };
HeaderManagerInstances.push(instance);
return instance;
});
const BulkContextMenuMock = vi.fn(() => {
const instance = { id: Symbol('BulkContextMenu') };
bulkContextMenuInstances.push(instance);
return instance;
});
const ExampleImagesManagerMock = vi.fn(() => {
const instance = { initialize: exampleImagesManagerInitializeMock };
globalThis.exampleImagesManager = instance;
return instance;
});
vi.stubGlobal('exampleImagesManager', null);
vi.mock('./utils/storageHelpers.js', () => ({
migrateStorageItems: migrateStorageItemsMock
}));
vi.mock('./state/index.js', () => ({
state: mockState
}));
vi.mock('./managers/LoadingManager.js', () => ({
LoadingManager: LoadingManagerMock
}));
vi.mock('./managers/ModalManager.js', () => ({
ModalManager: vi.fn(),
modalManager: mockModalManager
}));
vi.mock('./managers/UpdateService.js', () => ({
updateService: mockUpdateService
}));
vi.mock('./components/Header.js', () => ({
HeaderManager: HeaderManagerMock
}));
vi.mock('./managers/SettingsManager.js', () => ({
settingsManager: mockSettingsManager
}));
vi.mock('./managers/MoveManager.js', () => ({
moveManager: mockMoveManager
}));
vi.mock('./managers/BulkManager.js', () => ({
bulkManager: mockBulkManager
}));
vi.mock('./managers/ExampleImagesManager.js', () => ({
ExampleImagesManager: ExampleImagesManagerMock
}));
vi.mock('./managers/HelpManager.js', () => ({
helpManager: mockHelpManager
}));
vi.mock('./managers/BannerService.js', () => ({
bannerService: mockBannerService
}));
vi.mock('./utils/uiHelpers.js', () => ({
initTheme: initThemeMock,
initBackToTop: initBackToTopMock
}));
vi.mock('./utils/infiniteScroll.js', () => ({
initializeInfiniteScroll: initializeInfiniteScrollMock
}));
vi.mock('./i18n/index.js', () => ({
i18n: mockI18n
}));
vi.mock('./managers/OnboardingManager.js', () => ({
onboardingManager: mockOnboardingManager
}));
vi.mock('./components/ContextMenu/BulkContextMenu.js', () => ({
BulkContextMenu: BulkContextMenuMock
}));
vi.mock('./components/ContextMenu/index.js', () => ({
createPageContextMenu: createPageContextMenuMock,
createGlobalContextMenu: createGlobalContextMenuMock
}));
vi.mock('./utils/eventManagementInit.js', () => ({
initializeEventManagement: initializeEventManagementMock
}));
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
document.body.removeAttribute('data-page');
mockState.currentPageType = 'loras';
mockState.global.settings.card_info_display = 'hover';
isBannerVisibleMock.mockReturnValue(false);
loadingManagerInstances.length = 0;
HeaderManagerInstances.length = 0;
bulkContextMenuInstances.length = 0;
delete window.pageContextMenu;
delete window.globalContextMenuInstance;
});
afterEach(() => {
vi.useRealTimers();
});
const loadCoreModule = async () => {
vi.resetModules();
return import('./core.js');
};
describe('AppCore module bootstrapping', () => {
it('registers storage migration on DOMContentLoaded', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
await loadCoreModule();
expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
const listener = addEventListenerSpy.mock.calls.find(([event]) => event === 'DOMContentLoaded')[1];
listener();
expect(migrateStorageItemsMock).toHaveBeenCalledTimes(1);
});
it('initializes managers, UI helpers, and onboarding sequence', async () => {
vi.useFakeTimers();
const { AppCore } = await loadCoreModule();
const appCore = new AppCore();
document.body.dataset.page = 'loras';
const result = await appCore.initialize();
expect(result).toBe(appCore);
expect(i18nWaitMock).toHaveBeenCalled();
expect(i18nLocaleMock).toHaveBeenCalled();
expect(settingsWaitMock).toHaveBeenCalled();
expect(LoadingManagerMock).toHaveBeenCalledTimes(1);
const loadingInstance = LoadingManagerMock.mock.results[0].value;
expect(mockState.loadingManager).toBe(loadingInstance);
expect(mockModalManager.initialize).toHaveBeenCalled();
expect(updateServiceInitializeMock).toHaveBeenCalled();
expect(bannerServiceInitializeMock).toHaveBeenCalled();
expect(window.modalManager).toBe(mockModalManager);
expect(window.settingsManager).toBe(mockSettingsManager);
expect(window.bulkManager).toBe(mockBulkManager);
expect(window.helpManager).toBe(mockHelpManager);
expect(HeaderManagerMock).toHaveBeenCalledTimes(1);
expect(initThemeMock).toHaveBeenCalled();
expect(initBackToTopMock).toHaveBeenCalled();
expect(bulkManagerInitializeMock).toHaveBeenCalled();
expect(BulkContextMenuMock).toHaveBeenCalledTimes(1);
expect(setBulkContextMenuMock).toHaveBeenCalledWith(bulkContextMenuInstances[0]);
expect(ExampleImagesManagerMock).toHaveBeenCalledTimes(1);
expect(exampleImagesManagerInitializeMock).toHaveBeenCalled();
expect(helpManagerInitializeMock).toHaveBeenCalled();
expect(document.body.classList.contains('hover-reveal')).toBe(true);
expect(initializeEventManagementMock).toHaveBeenCalled();
vi.runAllTimers();
expect(onboardingStartMock).toHaveBeenCalled();
});
it('skips bulk manager when recipes page is active', async () => {
const { AppCore } = await loadCoreModule();
const appCore = new AppCore();
mockState.currentPageType = 'recipes';
await appCore.initialize();
expect(bulkManagerInitializeMock).not.toHaveBeenCalled();
expect(BulkContextMenuMock).not.toHaveBeenCalled();
expect(setBulkContextMenuMock).not.toHaveBeenCalled();
});
it('initializes page features with context menus and infinite scroll', async () => {
const { AppCore } = await loadCoreModule();
const appCore = new AppCore();
document.body.dataset.page = 'loras';
appCore.initializeContextMenus = vi.fn(appCore.initializeContextMenus.bind(appCore));
appCore.initializePageFeatures();
expect(appCore.initializeContextMenus).toHaveBeenCalledWith('loras');
expect(initializeInfiniteScrollMock).toHaveBeenCalledWith('loras');
expect(createPageContextMenuMock).toHaveBeenCalledWith('loras');
expect(window.pageContextMenu).toBe('page-menu');
expect(createGlobalContextMenuMock).toHaveBeenCalled();
expect(window.globalContextMenuInstance).toBe('global-menu');
});
it('does not reinitialize once initialized', async () => {
const { AppCore } = await loadCoreModule();
const appCore = new AppCore();
await appCore.initialize();
await appCore.initialize();
expect(i18nWaitMock).toHaveBeenCalledTimes(1);
expect(settingsWaitMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as storageHelpers from './storageHelpers.js';
const {
getStorageItem,
setStorageItem,
removeStorageItem,
getSessionItem,
setSessionItem,
removeSessionItem,
migrateStorageItems
} = storageHelpers;
const createFakeStorage = () => {
const store = new Map();
return {
getItem: vi.fn((key) => (store.has(key) ? store.get(key) : null)),
setItem: vi.fn((key, value) => {
store.set(key, value);
}),
removeItem: vi.fn((key) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
key: vi.fn((index) => Array.from(store.keys())[index] ?? null),
get length() {
return store.size;
},
_store: store
};
};
let localStorageMock;
let sessionStorageMock;
let consoleLogMock;
beforeEach(() => {
localStorageMock = createFakeStorage();
sessionStorageMock = createFakeStorage();
vi.stubGlobal('localStorage', localStorageMock);
vi.stubGlobal('sessionStorage', sessionStorageMock);
consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('storageHelpers namespace utilities', () => {
it('returns parsed JSON for prefixed localStorage items', () => {
localStorage.setItem('lora_manager_preferences', JSON.stringify({ theme: 'dark' }));
const result = getStorageItem('preferences');
expect(result).toEqual({ theme: 'dark' });
expect(localStorage.getItem).toHaveBeenCalledWith('lora_manager_preferences');
});
it('falls back to legacy keys and migrates them to the namespace', () => {
localStorage.setItem('legacy_key', 'value');
const value = getStorageItem('legacy_key');
expect(value).toBe('value');
expect(localStorage.getItem('lora_manager_legacy_key')).toBe('value');
});
it('serializes objects when setting prefixed localStorage values', () => {
const data = { ids: [1, 2, 3] };
setStorageItem('data', data);
expect(localStorage.setItem).toHaveBeenCalledWith('lora_manager_data', JSON.stringify(data));
expect(localStorage.getItem('lora_manager_data')).toEqual(JSON.stringify(data));
});
it('removes both prefixed and legacy localStorage entries', () => {
localStorage.setItem('lora_manager_temp', '123');
localStorage.setItem('temp', '456');
removeStorageItem('temp');
expect(localStorage.getItem('lora_manager_temp')).toBeNull();
expect(localStorage.getItem('temp')).toBeNull();
});
it('returns parsed JSON for session storage items', () => {
sessionStorage.setItem('lora_manager_session', JSON.stringify({ page: 'loras' }));
const session = getSessionItem('session');
expect(session).toEqual({ page: 'loras' });
});
it('stores primitives in session storage directly', () => {
setSessionItem('token', 'abc123');
expect(sessionStorage.setItem).toHaveBeenCalledWith('lora_manager_token', 'abc123');
expect(sessionStorage.getItem('lora_manager_token')).toBe('abc123');
});
it('removes session storage entries by namespace', () => {
sessionStorage.setItem('lora_manager_flag', '1');
removeSessionItem('flag');
expect(sessionStorage.getItem('lora_manager_flag')).toBeNull();
});
});
describe('migrateStorageItems', () => {
it('migrates known keys and logs completion', () => {
const setStorageSpy = vi.spyOn(storageHelpers, 'setStorageItem');
localStorage.setItem('theme', '"light"');
localStorage.setItem('loras_filters', JSON.stringify({ sort: 'asc' }));
localStorage.setItem('nsfwBlurLevel', '3');
migrateStorageItems();
expect(setStorageSpy).toHaveBeenCalledTimes(3);
expect(localStorage.getItem('lora_manager_theme')).toBe('light');
expect(localStorage.getItem('lora_manager_loras_filters')).toBe(JSON.stringify({ sort: 'asc' }));
expect(localStorage.getItem('loras_filters')).toBeNull();
expect(localStorage.getItem('lora_manager_nsfwBlurLevel')).toBe('3');
expect(localStorage.getItem('nsfwBlurLevel')).toBeNull();
expect(localStorage.getItem('lora_manager_migration_completed')).toBe('true');
expect(consoleLogMock).toHaveBeenCalledWith('Lora Manager: Storage migration completed');
});
it('skips migration when already completed and logs notice', () => {
const setStorageSpy = vi.spyOn(storageHelpers, 'setStorageItem');
localStorage.setItem('lora_manager_migration_completed', 'true');
migrateStorageItems();
expect(setStorageSpy).not.toHaveBeenCalled();
expect(consoleLogMock).toHaveBeenCalledWith('Lora Manager: Storage migration already completed');
});
});