diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index be4a7c2374..7cd7a4de17 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -250,6 +250,10 @@ export default function ImageGen() { // apply to it. Keep the target state intact so switching back to another // backend restores the user's previous target selection. const remoteTargetActive = effectiveMode !== IMAGE_GEN_MODE.GROK && remoteTarget.isRemote; + // The status probe describes THIS machine's backend, and it can hang for a long + // time against an unconfigured external SD API URL. A federated target renders + // on the peer, so only a LOCAL dispatch waits on the probe. + const localBackendPending = statusLoading && !remoteTargetActive; const isAsyncMode = isLocalMode || isCloudMode || remoteTargetActive; // Only probe `agy models` while Agy is the active backend — it spawns a // child process server-side, so an unselected backend must not pay for it. @@ -983,6 +987,11 @@ export default function ImageGen() { // fires onSubmit — gate here too so an edit-only model without a source image // (or codex text-to-image with no prompt) hits the inline hint, not a 400 toast. if (editImageMissing || cloudNeedsPrompt) return; + // Same reason, for the backend probe: the form stays typable while the status + // pill is still checking, so an implicit submit must not dispatch against a + // backend we haven't confirmed. A remote target runs on the peer, so the + // LOCAL probe result doesn't gate it — mirror the submit button exactly. + if (localBackendPending || (!remoteTargetActive && notConnected)) return; // The button reading is as old as the last render and a capacity window // expires on the clock, so an enabled button can already be pointing at a // lapsed peer. Re-derive here and say so, rather than letting the server @@ -1293,12 +1302,10 @@ export default function ImageGen() {
@@ -1306,7 +1313,6 @@ export default function ImageGen() { value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} - disabled={statusLoading} className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]" placeholder="Describe the image you want to generate..." /> @@ -1316,7 +1322,6 @@ export default function ImageGen() { value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={3} - disabled={statusLoading} className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]" placeholder="What to avoid..." /> @@ -1330,14 +1335,12 @@ export default function ImageGen() { negativePrompt={negativePrompt} setNegativePrompt={setNegativePrompt} renderConfig={{ stylePreset: stylePreset?.id, mode: effectiveMode }} - disabled={statusLoading} /> {flux2Issue === 'venv' && ( @@ -1349,7 +1352,6 @@ export default function ImageGen() { {editImageMissing && ( diff --git a/client/src/pages/ImageGen.probeGating.test.jsx b/client/src/pages/ImageGen.probeGating.test.jsx new file mode 100644 index 0000000000..35b37714dd --- /dev/null +++ b/client/src/pages/ImageGen.probeGating.test.jsx @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 }; + +// A peer opted in as an image provider with a live capacity window — the shape +// `GET /api/instances` returns. +const PEER = { + id: 'peer-example', + name: 'Example GPU', + status: 'online', + enabled: true, + mediaProvider: { enabled: true, imageModels: [{ engine: 'local', modelId: 'peer-flux' }] }, + mediaProviderStatus: { + state: 'ready', + checkedAt: new Date().toISOString(), + freshUntil: new Date(Date.now() + 60_000).toISOString(), + snapshot: { + queue: { accepting: true, running: 0, queued: 0, totalActive: 0, maxQueuedJobs: 4 }, + capabilities: [{ + kind: 'image', engine: 'local', engineName: 'Local image', modelId: 'peer-flux', + modelName: 'FLUX.2 Klein', ready: true, unavailableReason: null, + runtimeReady: true, platformSupported: true, cudaRequired: false, cudaState: 'available', + }], + }, + }, +}; + +// The backend probe is held open on purpose: an unconfigured `external` SD API +// URL times out, and that window used to grey out the whole form. +const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null, generateImage: vi.fn() })); + +vi.mock('../services/api', () => ({ + getInstances: vi.fn(async () => ({ peers: [] })), + getImageGenStatus: vi.fn(() => state.statusPromise), + generateImage: (...args) => state.generateImage(...args), + generateImageMultipart: vi.fn(async () => ({})), + listImageModels: vi.fn(async () => [MODEL]), + listLorasFull: vi.fn(async () => []), + listImageGallery: vi.fn(async () => []), + cancelImageGen: vi.fn(async () => ({})), + deleteImage: vi.fn(async () => ({})), + setImageHidden: vi.fn(async () => ({})), + cleanGalleryImage: vi.fn(async () => ({})), + getActiveImageJob: vi.fn(async () => ({ activeJob: null })), + getSettings: vi.fn(async () => ({ imageGen: { mode: 'local', local: { pythonPath: '/usr/bin/python3' } } })), + buildFormData: vi.fn(() => new FormData()), + listMediaJobs: vi.fn(async () => ({ jobs: [] })), + regenerateGalleryImage: vi.fn(async () => ({})), + getRegenAvailability: vi.fn(async () => ({ available: false })), + removeImageWatermark: vi.fn(async () => ({})), + getFlux2Status: vi.fn(async () => ({ installed: true, ready: true })), +})); + +vi.mock('../hooks/useImageGenProgress', () => ({ + useImageGenProgress: () => ({ progress: null, begin: vi.fn(), end: vi.fn(), resume: vi.fn() }), +})); +vi.mock('../hooks/useMediaJobSse', () => ({ + useMediaJobSse: () => ({ attach: vi.fn(), eventSourceRef: { current: null } }), +})); +vi.mock('../hooks/useModelDownloadStatus', () => ({ + useModelDownloadStatus: () => ({ + getStatus: () => ({ cached: true }), start: vi.fn(), cancel: vi.fn(), repair: vi.fn(), refresh: vi.fn(), + downloading: false, repairing: false, progress: null, lastError: null, activeModelId: null, extra: {}, loading: false, statusError: null, + }), +})); +vi.mock('../hooks/useHfTokenStatus', () => ({ useHfTokenStatus: () => ({ present: true, refresh: vi.fn() }) })); +vi.mock('../hooks/useAgyModels', () => ({ useAgyModels: () => ({ models: [], error: null }) })); +vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() })); +vi.mock('../hooks/useMediaAnnotations', () => ({ + useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }), +})); +vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: vi.fn() })); +vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] })); +vi.mock('../components/ui/Toast', () => ({ + default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }), +})); +vi.mock('../components/media/PromptEnhancer', () => ({ default: () => null })); +vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null })); +vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null })); +vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null })); +vi.mock('../components/media/MediaPreview', () => ({ default: () => null })); +vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null })); +vi.mock('../components/media/ResolutionField', () => ({ default: () => null })); +vi.mock('../components/Drawer', () => ({ default: () => null })); +vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null })); +vi.mock('../components/imageGen/Flux2InstallModal', () => ({ default: () => null })); +vi.mock('../components/imageGen/GalleryImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/InitImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/ReferenceImagePicker', () => ({ default: () => null })); +vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null })); + +const { default: ImageGen } = await import('./ImageGen.jsx'); + +const mount = async () => { + await act(async () => { + render( + + + , + ); + }); +}; + +describe('ImageGen backend-probe gating', () => { + beforeEach(() => { + state.generateImage.mockReset().mockResolvedValue({ jobId: 'job-1' }); + state.statusPromise = new Promise((resolve) => { state.resolveStatus = resolve; }); + }); + + // The probe decides which backend can RUN, not what the user may TYPE. While + // it is in flight the whole above-the-fold form must stay usable. + it('leaves the prompt fields editable while the status probe is still in flight', async () => { + await mount(); + + expect(await screen.findByLabelText('Prompt')).not.toBeDisabled(); + expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /Checking…/ })).toBeDisabled(); + }); + + // A probe that comes back unusable must still not take the form hostage — + // only submit stays blocked, so the user can compose while they fix settings. + it('keeps the prompt editable and submit blocked when the probe reports not connected', async () => { + await mount(); + await act(async () => { + state.resolveStatus({ connected: false, mode: 'local', reason: 'Not configured' }); + await state.statusPromise; + }); + + await waitFor(() => expect(screen.getByRole('button', { name: /^Generate$/ })).toBeDisabled()); + expect(screen.getByLabelText('Prompt')).not.toBeDisabled(); + expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled(); + }); + + // A live form has a live implicit submit: Enter inside a number input fires + // onSubmit even when the default button is disabled, so the handler carries + // the same probe gate the button does. + it('refuses an implicit submit fired while the probe is still in flight', async () => { + await mount(); + + const prompt = await screen.findByLabelText('Prompt'); + fireEvent.change(prompt, { target: { value: 'a lighthouse at dusk' } }); + await act(async () => { fireEvent.submit(prompt.closest('form')); }); + + expect(state.generateImage).not.toHaveBeenCalled(); + }); + + // A federated render runs on the peer, so THIS machine's probe — hung against + // an unconfigured SD API URL — must not hold the submit hostage. + it('still submits to a ready peer while the local probe hangs', async () => { + const { getInstances } = await import('../services/api'); + getInstances.mockResolvedValueOnce({ peers: [PEER] }); + await mount(); + + fireEvent.change(await screen.findByRole('combobox', { name: /generation target/i }), { target: { value: 'peer-example' } }); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'a lighthouse at dusk' } }); + + const generate = screen.getByRole('button', { name: /^Generate$/ }); + expect(generate).not.toBeDisabled(); + await act(async () => { fireEvent.click(generate); }); + + await waitFor(() => expect(state.generateImage).toHaveBeenCalled()); + expect(state.generateImage.mock.calls[0][0]).toMatchObject({ mediaProviderPeerId: 'peer-example' }); + }); +});