diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md index 50025cb7..2793b64c 100644 --- a/docs/frontend-testing-roadmap.md +++ b/docs/frontend-testing-roadmap.md @@ -9,7 +9,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR | 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` | ✅ 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 | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating | -| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | 🚧 In Progress | LoRA + checkpoints smoke suites landed; filter/sort scenario matrix drafted to guide upcoming specs | +| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows | | 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 | diff --git a/static/js/embeddings.js b/static/js/embeddings.js index 1352471c..654d250c 100644 --- a/static/js/embeddings.js +++ b/static/js/embeddings.js @@ -36,12 +36,18 @@ class EmbeddingsPageManager { } } -// Initialize everything when DOM is ready -document.addEventListener('DOMContentLoaded', async () => { +async function initializeEmbeddingsPage() { // Initialize core application await appCore.initialize(); - + // Initialize embeddings page const embeddingsPage = new EmbeddingsPageManager(); await embeddingsPage.initialize(); -}); + + return embeddingsPage; +} + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', initializeEmbeddingsPage); + +export { EmbeddingsPageManager, initializeEmbeddingsPage }; diff --git a/tests/frontend/pages/embeddingsPage.test.js b/tests/frontend/pages/embeddingsPage.test.js new file mode 100644 index 00000000..4d0f754f --- /dev/null +++ b/tests/frontend/pages/embeddingsPage.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderEmbeddingsPage } from '../utils/pageFixtures.js'; + +const initializeAppMock = vi.fn(); +const initializePageFeaturesMock = vi.fn(); +const createPageControlsMock = vi.fn(); +const confirmDeleteMock = vi.fn(); +const closeDeleteModalMock = vi.fn(); +const confirmExcludeMock = vi.fn(); +const closeExcludeModalMock = vi.fn(); +const duplicatesManagerMock = vi.fn(); + +vi.mock('../../../static/js/core.js', () => ({ + appCore: { + initialize: initializeAppMock, + initializePageFeatures: initializePageFeaturesMock, + }, +})); + +vi.mock('../../../static/js/components/controls/index.js', () => ({ + createPageControls: createPageControlsMock, +})); + +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + confirmDelete: confirmDeleteMock, + closeDeleteModal: closeDeleteModalMock, + confirmExclude: confirmExcludeMock, + closeExcludeModal: closeExcludeModalMock, +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + EMBEDDING: 'embeddings', + }, +})); + +vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ + ModelDuplicatesManager: duplicatesManagerMock, +})); + +describe('EmbeddingsPageManager', () => { + let EmbeddingsPageManager; + let initializeEmbeddingsPage; + let duplicatesManagerInstance; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + duplicatesManagerInstance = { + checkDuplicatesCount: vi.fn(), + }; + + duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance); + createPageControlsMock.mockReturnValue({ destroy: vi.fn() }); + initializeAppMock.mockResolvedValue(undefined); + + renderEmbeddingsPage(); + + ({ EmbeddingsPageManager, initializeEmbeddingsPage } = await import('../../../static/js/embeddings.js')); + }); + + afterEach(() => { + delete window.confirmDelete; + delete window.closeDeleteModal; + delete window.confirmExclude; + delete window.closeExcludeModal; + delete window.modelDuplicatesManager; + }); + + it('wires page controls and exposes modal helpers during construction', () => { + const manager = new EmbeddingsPageManager(); + + expect(createPageControlsMock).toHaveBeenCalledWith('embeddings'); + expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'embeddings'); + + expect(window.confirmDelete).toBe(confirmDeleteMock); + expect(window.closeDeleteModal).toBe(closeDeleteModalMock); + expect(window.confirmExclude).toBe(confirmExcludeMock); + expect(window.closeExcludeModal).toBe(closeExcludeModalMock); + expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance); + }); + + it('initializes shared page features', async () => { + const manager = new EmbeddingsPageManager(); + + await manager.initialize(); + + expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); + }); + + it('boots the embeddings page through the initializer', async () => { + const manager = await initializeEmbeddingsPage(); + + expect(initializeAppMock).toHaveBeenCalledTimes(1); + expect(manager).toBeInstanceOf(EmbeddingsPageManager); + expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance); + }); +}); diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js new file mode 100644 index 00000000..74add4e0 --- /dev/null +++ b/tests/frontend/pages/recipesPage.test.js @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderRecipesPage } from '../utils/pageFixtures.js'; + +const initializeAppMock = vi.fn(); +const initializePageFeaturesMock = vi.fn(); +const getCurrentPageStateMock = vi.fn(); +const getSessionItemMock = vi.fn(); +const removeSessionItemMock = vi.fn(); +const RecipeContextMenuMock = vi.fn(); +const refreshVirtualScrollMock = vi.fn(); +const refreshRecipesMock = vi.fn(); + +let importManagerInstance; +let recipeModalInstance; +let duplicatesManagerInstance; + +const ImportManagerMock = vi.fn(() => importManagerInstance); +const RecipeModalMock = vi.fn(() => recipeModalInstance); +const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance); + +vi.mock('../../../static/js/core.js', () => ({ + appCore: { + initialize: initializeAppMock, + initializePageFeatures: initializePageFeaturesMock, + }, +})); + +vi.mock('../../../static/js/managers/ImportManager.js', () => ({ + ImportManager: ImportManagerMock, +})); + +vi.mock('../../../static/js/components/RecipeModal.js', () => ({ + RecipeModal: RecipeModalMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + getCurrentPageState: getCurrentPageStateMock, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + getSessionItem: getSessionItemMock, + removeSessionItem: removeSessionItemMock, +})); + +vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ + RecipeContextMenu: RecipeContextMenuMock, +})); + +vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({ + DuplicatesManager: DuplicatesManagerMock, +})); + +vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ + refreshVirtualScroll: refreshVirtualScrollMock, +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + refreshRecipes: refreshRecipesMock, +})); + +describe('RecipeManager', () => { + let RecipeManager; + let pageState; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + importManagerInstance = { + showImportModal: vi.fn(), + }; + recipeModalInstance = { + showRecipeDetails: vi.fn(), + }; + duplicatesManagerInstance = { + findDuplicates: vi.fn(), + selectLatestDuplicates: vi.fn(), + deleteSelectedDuplicates: vi.fn(), + confirmDeleteDuplicates: vi.fn(), + exitDuplicateMode: vi.fn(), + }; + + pageState = { + sortBy: 'date', + searchOptions: undefined, + customFilter: undefined, + duplicatesMode: false, + }; + + getCurrentPageStateMock.mockImplementation(() => pageState); + initializeAppMock.mockResolvedValue(undefined); + initializePageFeaturesMock.mockResolvedValue(undefined); + refreshVirtualScrollMock.mockReset(); + refreshVirtualScrollMock.mockImplementation(() => {}); + refreshRecipesMock.mockResolvedValue('refreshed'); + + getSessionItemMock.mockImplementation((key) => { + const map = { + lora_to_recipe_filterLoraName: 'Flux Dream', + lora_to_recipe_filterLoraHash: 'abc123', + viewRecipeId: '42', + }; + return map[key] ?? null; + }); + removeSessionItemMock.mockImplementation(() => {}); + + renderRecipesPage(); + + ({ RecipeManager } = await import('../../../static/js/recipes.js')); + }); + + afterEach(() => { + delete window.recipeManager; + delete window.importManager; + }); + + it('initializes page controls, restores filters, and wires sort interactions', async () => { + const sortSelectElement = document.createElement('select'); + sortSelectElement.id = 'sortSelect'; + sortSelectElement.innerHTML = ` + + + `; + document.body.appendChild(sortSelectElement); + + const manager = new RecipeManager(); + await manager.initialize(); + + expect(ImportManagerMock).toHaveBeenCalledTimes(1); + expect(RecipeModalMock).toHaveBeenCalledTimes(1); + expect(DuplicatesManagerMock).toHaveBeenCalledWith(manager); + expect(RecipeContextMenuMock).toHaveBeenCalledTimes(1); + + expect(window.recipeManager).toBe(manager); + expect(window.importManager).toBe(importManagerInstance); + + expect(pageState.searchOptions).toEqual({ + title: true, + tags: true, + loraName: true, + loraModel: true, + }); + + expect(pageState.customFilter).toEqual({ + active: true, + loraName: 'Flux Dream', + loraHash: 'abc123', + recipeId: '42', + }); + + const indicator = document.getElementById('customFilterIndicator'); + expect(indicator.classList.contains('hidden')).toBe(false); + + const clearButton = indicator.querySelector('.clear-filter'); + clearButton.dispatchEvent(new Event('click', { bubbles: true })); + + expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName'); + expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash'); + expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId'); + expect(pageState.customFilter.active).toBe(false); + expect(indicator.classList.contains('hidden')).toBe(true); + expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); + + const sortSelect = document.getElementById('sortSelect'); + sortSelect.value = 'name'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + + expect(pageState.sortBy).toBe('name'); + expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2); + expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); + }); + + it('skips loading when duplicates mode is active and refreshes otherwise', async () => { + const manager = new RecipeManager(); + + pageState.duplicatesMode = true; + await manager.loadRecipes(); + expect(refreshVirtualScrollMock).not.toHaveBeenCalled(); + + pageState.duplicatesMode = false; + await manager.loadRecipes(); + expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); + }); + + it('proxies duplicate management and refresh helpers', async () => { + const manager = new RecipeManager(); + + await manager.findDuplicateRecipes(); + expect(duplicatesManagerInstance.findDuplicates).toHaveBeenCalledTimes(1); + + manager.selectLatestDuplicates(); + expect(duplicatesManagerInstance.selectLatestDuplicates).toHaveBeenCalledTimes(1); + + manager.deleteSelectedDuplicates(); + expect(duplicatesManagerInstance.deleteSelectedDuplicates).toHaveBeenCalledTimes(1); + + manager.confirmDeleteDuplicates(); + expect(duplicatesManagerInstance.confirmDeleteDuplicates).toHaveBeenCalledTimes(1); + + const grid = document.getElementById('recipeGrid'); + grid.innerHTML = '