diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md index 628b5b1e..e39e7c03 100644 --- a/docs/frontend-testing-roadmap.md +++ b/docs/frontend-testing-roadmap.md @@ -7,17 +7,17 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR | 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 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 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 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 | 🟡 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 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 | ## 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. -- [ ] 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). Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress. diff --git a/static/js/core.test.js b/static/js/core.test.js new file mode 100644 index 00000000..e39d5af0 --- /dev/null +++ b/static/js/core.test.js @@ -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); + }); +}); diff --git a/static/js/utils/storageHelpers.test.js b/static/js/utils/storageHelpers.test.js new file mode 100644 index 00000000..ed19b967 --- /dev/null +++ b/static/js/utils/storageHelpers.test.js @@ -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'); + }); +});