diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md index 2793b64c..952b5af8 100644 --- a/docs/frontend-testing-roadmap.md +++ b/docs/frontend-testing-roadmap.md @@ -10,7 +10,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR | 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 | ✅ 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 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus | | 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 @@ -20,6 +20,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR - [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies. - [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures. - [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios. +- [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4. - [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs). - [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added. - [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3. diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js new file mode 100644 index 00000000..7dd18ad5 --- /dev/null +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -0,0 +1,319 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const copyToClipboardMock = vi.fn(); +const getNSFWLevelNameMock = vi.fn((level) => { + if (level >= 16) return 'XXX'; + if (level >= 8) return 'X'; + if (level >= 4) return 'R'; + if (level >= 2) return 'PG13'; + if (level >= 1) return 'PG'; + return 'Unknown'; +}); +const copyLoraSyntaxMock = vi.fn(); +const sendLoraToWorkflowMock = vi.fn(); +const buildLoraSyntaxMock = vi.fn((fileName) => `lora:${fileName}`); +const openExampleImagesFolderMock = vi.fn(); + +const modalManagerMock = { + showModal: vi.fn(), + closeModal: vi.fn(), + registerModal: vi.fn(), + getModal: vi.fn(() => ({ element: { style: { display: 'none' } }, isOpen: false })), + isAnyModalOpen: vi.fn(), +}; + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + show: vi.fn(), +}; + +const stateStub = { + global: { settings: {}, loadingManager: loadingManagerStub }, + loadingManager: loadingManagerStub, + virtualScroller: { updateSingleItem: vi.fn() }, +}; + +const saveModelMetadataMock = vi.fn(); +const downloadExampleImagesApiMock = vi.fn(); +const replaceModelPreviewMock = vi.fn(); +const refreshSingleModelMetadataMock = vi.fn(); +const resetAndReloadMock = vi.fn(); + +const getModelApiClientMock = vi.fn(() => ({ + saveModelMetadata: saveModelMetadataMock, + downloadExampleImages: downloadExampleImagesApiMock, + replaceModelPreview: replaceModelPreviewMock, + refreshSingleModelMetadata: refreshSingleModelMetadataMock, +})); + +const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true })); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: copyToClipboardMock, + getNSFWLevelName: getNSFWLevelNameMock, + copyLoraSyntax: copyLoraSyntaxMock, + sendLoraToWorkflow: sendLoraToWorkflowMock, + buildLoraSyntax: buildLoraSyntaxMock, + openExampleImagesFolder: openExampleImagesFolderMock, +})); + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: modalManagerMock, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + getSessionItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: getModelApiClientMock, + resetAndReload: resetAndReloadMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + showExcludeModal: vi.fn(), + showDeleteModal: vi.fn(), +})); + +vi.mock('../../../static/js/managers/MoveManager.js', () => ({ + moveManager: { showMoveModal: vi.fn() }, +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + updateRecipeMetadata: updateRecipeMetadataMock, +})); + +async function flushAsyncTasks() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('Interaction-level regression coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + document.body.innerHTML = ''; + stateStub.global.settings = {}; + saveModelMetadataMock.mockResolvedValue(undefined); + downloadExampleImagesApiMock.mockResolvedValue(undefined); + updateRecipeMetadataMock.mockResolvedValue({ success: true }); + global.modalManager = modalManagerMock; + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + delete window.exampleImagesManager; + delete global.fetch; + delete global.modalManager; + }); + + it('opens the NSFW selector from the LoRA context menu and persists the new rating', async () => { + document.body.innerHTML = ` +
+ + `; + + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/test.safetensors'; + card.dataset.meta = JSON.stringify({ preview_nsfw_level: 1 }); + document.body.appendChild(card); + + const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js'); + const helpers = await import('../../../static/js/utils/uiHelpers.js'); + expect(helpers.showToast).toBe(showToastMock); + const contextMenu = new LoraContextMenu(); + + contextMenu.showMenu(120, 140, card); + + const nsfwMenuItem = document.querySelector('#loraContextMenu .context-menu-item[data-action="set-nsfw"]'); + nsfwMenuItem.dispatchEvent(new Event('click', { bubbles: true })); + + const selector = document.getElementById('nsfwLevelSelector'); + expect(selector.style.display).toBe('block'); + expect(selector.dataset.cardPath).toBe('/models/test.safetensors'); + expect(document.getElementById('currentNSFWLevel').textContent).toBe('PG'); + + const levelButton = selector.querySelector('.nsfw-level-btn[data-level="4"]'); + levelButton.dispatchEvent(new Event('click', { bubbles: true })); + + expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/test.safetensors', { preview_nsfw_level: 4 }); + expect(saveModelMetadataMock).toHaveBeenCalledTimes(1); + await saveModelMetadataMock.mock.results[0].value; + await flushAsyncTasks(); + expect(selector.style.display).toBe('none'); + expect(document.getElementById('loraContextMenu').style.display).toBe('none'); + }); + + it('wires recipe modal title editing to update metadata and UI state', async () => { + document.body.innerHTML = ` +