Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@
const selectedImages = useImageStore((state) => state.selectedImages);
const selectedImage = useImageStore((state) => state.selectedImage);
const previewImage = useImageStore((state) => state.previewImage);
const clustersCount = useImageStore((state) => state.clusters.length);

Check warning on line 305 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'clustersCount' is assigned a value but never used
const clusters = useImageStore((state) => state.clusters);
const clusterNavigationContext = useImageStore((state) => state.clusterNavigationContext);
const activeImageScope = useImageStore((state) => state.activeImageScope);
Expand Down Expand Up @@ -833,7 +833,7 @@
trialDaysRemaining,
canStartTrial,
isExpired,
isFree,

Check warning on line 836 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'isFree' is assigned a value but never used
isPro,
canUseAnalytics,
canUseBatchExport,
Expand Down Expand Up @@ -4623,6 +4623,8 @@
onGenerationStateChange={setIsComfyUIWorkspaceGenerating}
suspendBrowser={isGeneratingComfyUI || isComfyUIWorkspaceGenerating}
onOpenQueue={() => setIsQueueOpen(true)}
onOpenPromptLibrary={() => setLibraryView('prompts')}
onViewPromptSource={handleOpenFileFromDeepLink}
onOpenSettings={handleOpenGeneratorIntegrations}
directories={safeDirectories}
selectedDirectoryId={comfyUIWorkspaceDirectoryId}
Expand Down
204 changes: 204 additions & 0 deletions __tests__/ComfyUIWorkspace.context.test.tsx

Large diffs are not rendered by default.

48 changes: 47 additions & 1 deletion __tests__/ComfyUIWorkspace.preview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import ComfyUIWorkspace from '../components/ComfyUIWorkspace';
import type { IndexedImage } from '../types';
import { useImageStore } from '../store/useImageStore';
import { useSavedPromptStore } from '../store/useSavedPromptStore';
const promptMocks = vi.hoisted(() => ({ save: vi.fn() }));
vi.mock('../hooks/useSavePrompt', () => ({ useSavePrompt: () => promptMocks.save, buildSavedPromptSource: () => null }));
vi.mock('../hooks/useFeatureAccess', () => ({ useFeatureAccess: () => ({ canUseAdvancedPromptLibrary: true, canUseModelManager: true, showProModal: vi.fn() }) }));

vi.mock('../hooks/useGenerateWithComfyUI', () => ({
useGenerateWithComfyUI: () => ({ generateWithComfyUI: vi.fn(), isGenerating: false, generateStatus: null }),
Expand Down Expand Up @@ -70,6 +74,8 @@ describe('ComfyUIWorkspace image viewer entry point', () => {
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined;

beforeEach(() => {
localStorage.clear();
promptMocks.save.mockReset().mockResolvedValue(undefined);
useImageStore.getState().resetState();
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn();
Expand Down Expand Up @@ -102,6 +108,8 @@ describe('ComfyUIWorkspace image viewer entry point', () => {
});

afterEach(() => {
cleanup();
window.getSelection()?.removeAllRanges();
vi.unstubAllGlobals();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
Expand Down Expand Up @@ -149,4 +157,42 @@ describe('ComfyUIWorkspace image viewer entry point', () => {
expect(onViewFullMetadata).toHaveBeenCalledWith(image);
expect(screen.queryByRole('dialog', { name: /Workspace image preview/i })).toBeNull();
});

it('saves node selections directly as untitled prompts or blocks without opening a form', async () => {
let receive: (payload: { kind: 'prompt' | 'block'; text: string }) => void = () => {};
const unsubscribe = vi.fn();
window.electronAPI = { ...window.electronAPI, onComfyUISaveSelection: (callback: typeof receive) => { receive = callback; return unsubscribe; } } as never;
const mutate = vi.spyOn(useSavedPromptStore.getState(), 'mutate').mockResolvedValue('saved');
const image = createImage('node-selection');
const view = render(<ComfyUIWorkspace image={image} isActive={false} onOpenQueue={vi.fn()} onOpenSettings={vi.fn()} />);
const text = ' café\n<lora:test:1> ';
await act(async () => { receive({ kind: 'prompt', text }); });
expect(mutate).toHaveBeenLastCalledWith({ action: 'create', kind: 'prompt', item: { positivePrompt: text } });
expect(screen.getByText('Prompt saved.')).toBeTruthy();
await act(async () => { receive({ kind: 'block', text }); });
expect(mutate).toHaveBeenLastCalledWith({ action: 'create', kind: 'block', item: { text } });
expect(screen.getByText('Block saved.')).toBeTruthy();
expect(screen.queryByRole('dialog')).toBeNull();
view.unmount(); expect(unsubscribe).toHaveBeenCalled();
});

it('saves the image prompt and captures literal selected text before opening the block editor', async () => {
const image = createImage('selection');
image.metadata!.normalizedMetadata!.prompt = ' warm\n light ';
const mutate = vi.spyOn(useSavedPromptStore.getState(), 'mutate').mockResolvedValue('block');
const view = render(<ComfyUIWorkspace image={image} navigationImages={[image]} currentIndex={0} isActive={false} directoryPath="C:/synthetic" onOpenQueue={vi.fn()} onOpenSettings={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Save prompt' }));
await waitFor(() => expect(promptMocks.save).toHaveBeenCalledWith(image, expect.objectContaining({ directoryPath: 'C:/synthetic' })));
const prompt = view.container.querySelector('[data-prompt-text]')!;
const range = document.createRange(); range.selectNodeContents(prompt);
window.getSelection()!.addRange(range);
fireEvent.contextMenu(prompt);
const action = await screen.findByRole('button', { name: 'Save Selection as Block' });
window.getSelection()!.removeAllRanges();
fireEvent.click(action);
expect((screen.getByLabelText('Block text') as HTMLTextAreaElement).value).toBe(' warm\n light ');
fireEvent.click(screen.getByRole('button', { name: 'Save block' }));
await waitFor(() => expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ action: 'create', kind: 'block', item: expect.objectContaining({ text: ' warm\n light ' }) })));
expect(window.electronAPI?.comfyUIViewOpen).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions __tests__/ModelQuickActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ describe('quick model actions', () => {
await waitFor(() => expect(screen.getByRole('alert').textContent).toBe('Synthetic missing file'));
});
});

it('hides and unhides through local metadata commands', async () => {
const view = render(<ModelQuickActions item={item} />);
fireEvent.click(screen.getByRole('button', { name: 'Hide model' }));
await waitFor(() => expect(fakes.command).toHaveBeenCalledWith({ type: 'saveLocal', locationId: 'primary', patch: { hidden: true } }));
view.rerender(<ModelQuickActions item={{ ...item, localMetadata: { id: 'sha256:' + 'a'.repeat(64), tags: [], updatedAt: 1, hidden: true } }} />);
fireEvent.click(screen.getByRole('button', { name: 'Unhide model' }));
await waitFor(() => expect(fakes.command).toHaveBeenCalledWith({ type: 'saveLocal', locationId: 'primary', patch: { hidden: false } }));
});
51 changes: 51 additions & 0 deletions __tests__/ModelsWorkspace.storage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ vi.mock('../components/ModelUsagePanel', () => ({ default: () => null }));
vi.mock('../components/ModelDetailsPanels', () => ({ default: ({ item }: { item: ModelInspectorItem }) => <p>Details for {item.location.id}</p> }));
vi.mock('../components/UnifiedModelUpdatesPanel', () => ({ ModelUpdatesPanel: ({ locationIds }: { locationIds: string[] }) => <p>Update locations: {locationIds.join(',')}</p> }));
import ModelsWorkspace from '../components/ModelsWorkspace';
import WorkspaceModels from '../components/comfyui/WorkspaceModels';
import { executeModelCommand } from '../components/ModelManagerPanels';
vi.mock('../hooks/useFeatureAccess', () => ({ useFeatureAccess: () => ({ canUseModelManager: true }) }));
vi.mock('react-virtualized-auto-sizer', () => ({ default: ({ children }: { children: (size: { width: number; height: number }) => React.ReactNode }) => children({ width: 360, height: 450 }) }));

beforeEach(() => {
for (const fake of [fakes.open, fakes.collection, fakes.selection, fakes.refresh, fakes.scan, fakes.check, fakes.identify]) fake.mockReset().mockResolvedValue({ success: true });
Expand Down Expand Up @@ -128,3 +132,50 @@ describe('Storage workspace and Inspector integration', () => {
expect(separator.getAttribute('aria-valuenow')).toBe('320');
});
});

it('hides a model in both catalogs and restores it through Hidden without removing its files', async () => {
const command = vi.mocked(executeModelCommand);
command.mockImplementation(async action => {
if (action.type === 'saveLocal') {
const location = fakes.state.catalog.locations.find(entry => entry.id === action.locationId)!;
const id = location.sha256 ? `sha256:${location.sha256}` : `location:${location.id}`;
fakes.state = { ...fakes.state, localMetadata: { ...fakes.state.localMetadata, [id]: { id, tags: [], updatedAt: 1, ...action.patch } } };
}
});
const page = () => <><ModelsWorkspace /><WorkspaceModels active={false} onAddText={vi.fn()} /></>;
const view = render(page());
const sidebar = () => within(screen.getByRole('region', { name: 'Workspace Model Manager' }));
expect(sidebar().getByRole('button', { name: /model2/ })).toBeTruthy();
fireEvent.click(sidebar().getByRole('button', { name: /model2/ }));
expect(sidebar().getByRole('button', { name: 'Back to models' })).toBeTruthy();
fireEvent.contextMenu(screen.getByRole('button', { name: 'Select model2' }));
fireEvent.click(screen.getByRole('button', { name: 'Hide model' }));
await waitFor(() => expect(command).toHaveBeenCalledWith({ type: 'saveLocal', locationId: 'primary', patch: { hidden: true } }));
view.rerender(page());
expect(screen.queryByRole('button', { name: 'Select model2' })).toBeNull();
expect(sidebar().queryByRole('button', { name: /model2/ })).toBeNull();
expect(sidebar().queryByRole('button', { name: 'Back to models' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Hidden', exact: true }));
expect(screen.getByRole('button', { name: 'Select model2' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Select lora' })).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: 'Select model2' }));
fireEvent.click(screen.getByRole('button', { name: 'Unhide model' }));
await waitFor(() => expect(command).toHaveBeenCalledWith({ type: 'saveLocal', locationId: 'primary', patch: { hidden: false } }));
view.rerender(page());
fireEvent.click(screen.getByRole('button', { name: 'All', exact: true }));
expect(screen.getByRole('button', { name: 'Select model2' })).toBeTruthy();
expect(sidebar().getByRole('button', { name: /model2/ })).toBeTruthy();
expect(fakes.state.catalog.locations).toHaveLength(3);
expect(fakes.state.storage!.files).toHaveLength(3);
});

it('excludes all copies of hidden identities from updates and restores them when unhidden', async () => {
const hash = 'a'.repeat(64);
fakes.state.localMetadata = { [`sha256:${hash}`]: { hidden: true } } as ModelManagerSnapshot['localMetadata'];
const view = render(<ModelsWorkspace />);
fireEvent.click(screen.getByRole('button', { name: /What's new/ }));
view.rerender(<ModelsWorkspace />);
expect(screen.getByText('Update locations: lora')).toBeTruthy();
fakes.state = { ...fakes.state, localMetadata: {} }; view.rerender(<ModelsWorkspace />);
expect(screen.getByText('Update locations: primary,copy,lora')).toBeTruthy();
});
Loading
Loading