Compare commits

...

8 Commits

Author SHA1 Message Date
Connor Byrne
e2b44f34ea test: add coverage for migrated linearMode/ImagePreview and PackBanner
Two of the components migrated to useImage in this PR shipped without
test files, dragging codecov patch coverage to 54.23% (below the 80%
threshold). Add render/behavior tests covering:

- linearMode/ImagePreview: ZoomPane vs mobile rendering, useImage-driven
  width/height computation, showSize gating, and execution status
  message precedence.
- packBanner/PackBanner: default banner fallback, banner_url/icon
  precedence, and useImage error fallback to default banner.

Pushes patch coverage above the codecov threshold without changing any
production code.
2026-05-04 16:03:55 -07:00
Christian Byrne
33a067f6af Merge branch 'main' into refactor/to-useimage 2026-05-04 13:41:37 -07:00
bymyself
4ef4aae1f2 refactor(useImageCrop): use useImage and remove load/error event API
Replace the exported handleImageLoad/handleImageError handlers (and
the manual watch on imageUrl that toggled isLoading) with useImage
from @vueuse/core. Source is wrapped in computed() so useImage re-runs
when imageUrl changes.

- watch(isReady, imageState) -> set isLoading=false and call
  updateDisplayedDimensions with the loaded image (off-DOM but with
  guaranteed natural dimensions when isReady fires).
- watch(error) -> set isLoading=false and clear imageUrl (preserving
  prior handleImageError behaviour).
- updateDisplayedDimensions now accepts an optional image arg, falling
  back to imageEl.value for the resize observer path.
- Drop the imageCropLoadingAfterUrlChange helper (and its tests) — the
  behaviour is now expressed inline as a single watch.

WidgetImageCrop.vue: remove @load/@error wiring on the <img> and stop
destructuring handleImageLoad/handleImageError from useImageCrop.
Crucially, do NOT add brightness-50 to the image — the crop overlay
already darkens the surroundings via shadow-[0_0_0_9999px_rgba(0,0,0,0.5)].

Tests: mock useImage at module level so harnesses can drive isReady
and error refs synchronously, then update existing harness/render
tests to use triggerImageLoad/triggerImageError helpers in place of
calling vm.handleImageLoad/Error or dispatching DOM 'load' events.
2026-05-04 11:01:24 -07:00
bymyself
6194a4ffaa refactor(vueNodes/ImagePreview): migrate error detection to useImage
Replace imageError ref + handleImageError with useImage's reactive
error ref. Source wrapped in computed() so useImage re-runs on
currentImageUrl/imageAltText changes, and useImage auto-resets error
when the source changes (replacing the manual resets in setCurrentIndex
and the imageUrls watcher).

Keep the @load handler on the rendered <img> so handleImageLoad
receives the actual DOM <img> element to pass to syncLegacyNodeImgs
(useImage's state ref is an off-DOM Image() and is not a substitute).

Test mocks useImage at the module level so tests don't depend on
network behaviour.
2026-05-04 11:01:02 -07:00
bymyself
0e07883c99 refactor(linearMode/ImagePreview): use useImage for dimensions
Replace template ref + @load handler with useImage from @vueuse/core
for natural dimension extraction. Source wrapped in computed() so
useImage re-runs when src prop changes.

Read naturalWidth/Height from useImage's state ref (off-DOM Image)
rather than the rendered <img>, removing the need for a template ref
and avoiding the @load wiring.
2026-05-04 11:00:43 -07:00
bymyself
d27e1efeb9 refactor(LivePreview): use useImage and cache last good dimensions
Replace manual @load/@error handlers and imageError ref with useImage
from @vueuse/core. Source is wrapped in computed() so useImage re-runs
when imageUrl changes.

Cache the last successfully loaded naturalWidth/Height in refs that
update only when isReady fires. This avoids the placeholder text
flickering back to 'Calculating dimensions' each time imageUrl changes
during live preview streaming.

Test mocks useImage at module level and drives state/isReady/error
manually while preserving @testing-library/vue assertions.
2026-05-04 11:00:26 -07:00
bymyself
bf8422554d refactor(PackBanner): use useImage and split src ternary into v-if/v-else
Replace isImageError ref + @error handler on <img> with useImage from
@vueuse/core. Source is wrapped in computed() so useImage re-runs when
nodePack changes. Template now branches with v-if/v-else on isImageError
instead of a ternary on :src + :class for clearer reading order.
2026-05-04 11:00:11 -07:00
bymyself
c3ec8081b4 refactor(UserAvatar): use useImage with reactive computed source
Replace manual imageError ref + Avatar @error handler with useImage
from @vueuse/core. Wrap the source in computed() so useImage re-runs
when photoUrl/ariaLabel props change.

Test mocks useImage at module level to control the error ref while
preserving the @testing-library/vue behavioral test style.
2026-05-04 10:59:50 -07:00
14 changed files with 515 additions and 175 deletions

View File

@@ -1,11 +1,26 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { fireEvent, render, screen } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import UserAvatar from './UserAvatar.vue'
const i18n = createI18n({
@@ -23,6 +38,10 @@ const i18n = createI18n({
})
describe('UserAvatar', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
return render(UserAvatar, {
global: {
@@ -67,10 +86,10 @@ describe('UserAvatar', () => {
photoUrl: 'https://example.com/broken-image.jpg'
})
const img = screen.getByRole('img')
expect(screen.getByRole('img')).toBeInTheDocument()
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
await fireEvent.error(img)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()

View File

@@ -11,22 +11,24 @@
}"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"
/>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import Avatar from 'primevue/avatar'
import { computed, ref } from 'vue'
import { computed } from 'vue'
const { photoUrl, ariaLabel } = defineProps<{
photoUrl?: string | null
ariaLabel?: string
}>()
const imageError = ref(false)
const handleImageError = () => {
imageError.value = true
}
const { error: imageError } = useImage(
computed(() => ({
src: photoUrl ?? '',
alt: ariaLabel ?? ''
}))
)
const hasAvatar = computed(() => photoUrl && !imageError.value)
</script>

View File

@@ -20,8 +20,6 @@ function createDefaultCropState() {
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},

View File

@@ -29,8 +29,6 @@
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
@@ -181,8 +179,6 @@ const {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -16,10 +17,35 @@ import {
createMockSubgraphNode
} from '@/utils/__tests__/litegraphTestUtils'
import { imageCropLoadingAfterUrlChange, useImageCrop } from './useImageCrop'
import { useImageCrop } from './useImageCrop'
const resizeObserverCallbacks: Array<() => void> = []
const useImageMockState = vi.hoisted(() => {
return {
state: null as null | {
state: Ref<HTMLImageElement | undefined>
isReady: Ref<boolean>
error: Ref<unknown>
}
}
})
function getUseImageMock() {
if (!useImageMockState.state) {
useImageMockState.state = {
state: ref<HTMLImageElement | undefined>(undefined),
isReady: ref(false),
error: ref<unknown>(null)
}
}
return useImageMockState.state
}
function resetUseImageMock() {
useImageMockState.state = null
}
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
@@ -27,7 +53,8 @@ vi.mock('@vueuse/core', async () => {
useResizeObserver: (_target: unknown, cb: () => void) => {
resizeObserverCallbacks.push(cb)
return { stop: vi.fn() }
}
},
useImage: () => getUseImageMock()
}
})
@@ -177,10 +204,33 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
value: nh
})
}
;(vm.handleImageLoad as () => void)()
triggerImageLoad(nw, nh)
flushResizeObservers()
}
function triggerImageLoad(nw: number, nh: number) {
const mock = getUseImageMock()
const fakeImg = new Image()
Object.defineProperty(fakeImg, 'naturalWidth', {
configurable: true,
value: nw
})
Object.defineProperty(fakeImg, 'naturalHeight', {
configurable: true,
value: nh
})
mock.state.value = fakeImg
mock.error.value = null
mock.isReady.value = true
}
function triggerImageError() {
const mock = getUseImageMock()
mock.state.value = undefined
mock.isReady.value = false
mock.error.value = new Error('image failed to load')
}
const harnessCleanups: Array<() => void> = []
async function mountHarness(nodeId: NodeId = 2 as NodeId) {
@@ -202,28 +252,6 @@ async function flushTicks() {
await nextTick()
}
describe('imageCropLoadingAfterUrlChange', () => {
it('clears loading when url becomes null', () => {
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
})
it('keeps loading off when url stays null', () => {
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
})
it('starts loading when url changes to a new string', () => {
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
})
it('starts loading when first url is set', () => {
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
})
it('returns null when url is unchanged so caller can skip updating', () => {
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
})
})
describe('useImageCrop', () => {
let sourceNode: LGraphNode
let cropNode: LGraphNode
@@ -231,6 +259,7 @@ describe('useImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
outputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -382,7 +411,8 @@ describe('useImageCrop', () => {
configurable: true,
value: 0
})
;(vm.handleImageLoad as () => void)()
triggerImageLoad(0, 0)
await flushTicks()
vm.modelValue = { x: 0, y: 0, width: 100, height: 80 }
const style = vm.cropBoxStyle as Record<string, string>
expect(parseFloat(style.width)).toBeCloseTo(100, 1)
@@ -410,14 +440,16 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
expect(vm.isLoading).toBe(true)
;(vm.handleImageLoad as () => void)()
triggerImageLoad(800, 600)
await flushTicks()
expect(vm.isLoading).toBe(false)
})
it('clears imageUrl on image error', async () => {
const vm = await mountHarness()
expect(vm.imageUrl).toBeTruthy()
;(vm.handleImageError as () => void)()
triggerImageError()
await flushTicks()
expect(vm.imageUrl).toBeNull()
expect(vm.isLoading).toBe(false)
})
@@ -600,6 +632,7 @@ describe('WidgetImageCrop', () => {
beforeEach(() => {
resizeObserverCallbacks.length = 0
resetUseImageMock()
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
@@ -688,7 +721,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
triggerImageLoad(400, 400)
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
@@ -732,7 +765,7 @@ describe('WidgetImageCrop', () => {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
triggerImageLoad(400, 400)
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))

View File

@@ -1,4 +1,4 @@
import { useResizeObserver } from '@vueuse/core'
import { useImage, useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
@@ -23,19 +23,6 @@ const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
const CROP_BOX_BORDER = 2
/**
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
* `isLoading` (e.g. same URL).
*/
export function imageCropLoadingAfterUrlChange(
url: string | null,
previous: string | null | undefined
): boolean | null {
if (url == null) return false
if (url !== previous) return true
return null
}
export const ASPECT_RATIOS = {
'1:1': 1,
'3:4': 3 / 4,
@@ -193,17 +180,37 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
imageUrl.value = getInputImageUrl()
}
const {
state: imageState,
isReady: imageIsReady,
error: imageLoadError
} = useImage(computed(() => ({ src: imageUrl.value ?? '', alt: '' })))
watch(imageUrl, (url, previous) => {
const next = imageCropLoadingAfterUrlChange(url, previous)
if (next !== null) {
isLoading.value = next
if (url == null) {
isLoading.value = false
} else if (url !== previous) {
isLoading.value = true
}
})
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return
watch([imageIsReady, imageState], ([ready, img]) => {
if (!ready || !img) return
isLoading.value = false
updateDisplayedDimensions(img)
})
watch(imageLoadError, (err) => {
if (err) {
isLoading.value = false
imageUrl.value = null
}
})
const updateDisplayedDimensions = (loadedImg?: HTMLImageElement | null) => {
const img = loadedImg ?? imageEl.value
if (!img || !containerEl.value) return
const img = imageEl.value
const container = containerEl.value
naturalWidth.value = img.naturalWidth
@@ -370,16 +377,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
)
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
const capturePointer = (e: PointerEvent) => {
if (e.target instanceof HTMLElement) e.target.setPointerCapture(e.pointerId)
}
@@ -617,8 +614,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -0,0 +1,126 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady
})
}
})
const executionStatusMock = vi.hoisted(() => ({
message: null as Ref<string | null> | null
}))
vi.mock('@/renderer/extensions/linearMode/useExecutionStatus', async () => {
const { ref } = await import('vue')
executionStatusMock.message = ref<string | null>(null)
return {
useExecutionStatus: () => ({
executionStatusMessage: executionStatusMock.message
})
}
})
import ImagePreview from './ImagePreview.vue'
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderImagePreview(props: Record<string, unknown> = {}) {
return render(ImagePreview, {
props: { src: 'https://example.com/image.png', ...props },
global: {
plugins: [i18n],
stubs: {
ZoomPane: {
template: '<div data-testid="zoom-pane"><slot /></div>'
}
}
}
})
}
function setLoadedImage(width: number, height: number) {
const fakeImage = { naturalWidth: width, naturalHeight: height } as
| HTMLImageElement
| undefined
useImageMock.state!.value = fakeImage
useImageMock.isReady!.value = true
}
describe('ImagePreview (linearMode)', () => {
beforeEach(() => {
if (useImageMock.state) useImageMock.state.value = undefined
if (useImageMock.isReady) useImageMock.isReady.value = false
if (executionStatusMock.message) executionStatusMock.message.value = null
})
it('renders src inside ZoomPane in desktop mode', () => {
renderImagePreview()
expect(screen.getByTestId('zoom-pane')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('renders bare img when mobile is true', () => {
renderImagePreview({ mobile: true })
expect(screen.queryByTestId('zoom-pane')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
it('shows dimensions once the image is ready', async () => {
renderImagePreview()
setLoadedImage(800, 600)
await nextTick()
expect(screen.getByText('800 x 600')).toBeInTheDocument()
})
it('appends label when provided alongside dimensions', async () => {
renderImagePreview({ label: 'demo' })
setLoadedImage(64, 32)
await nextTick()
expect(screen.getByText(/64 x 32/)).toBeInTheDocument()
expect(screen.getByText(/demo/)).toBeInTheDocument()
})
it('does not show dimensions when showSize=false', async () => {
renderImagePreview({ showSize: false })
setLoadedImage(800, 600)
await nextTick()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
it('does not show dimensions before the image is ready', () => {
renderImagePreview()
expect(screen.queryByText(/x/)).not.toBeInTheDocument()
})
it('shows execution status message instead of dimensions when present', async () => {
renderImagePreview()
setLoadedImage(800, 600)
executionStatusMock.message!.value = 'Generating…'
await nextTick()
expect(screen.getByText('Generating…')).toBeInTheDocument()
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
@@ -16,15 +17,20 @@ const { src, showSize = true } = defineProps<{
showSize?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const { state: imageState, isReady } = useImage(
computed(() => ({ src, alt: '' }))
)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
const width = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalWidth || null
: null
)
const height = computed(() =>
showSize && isReady.value && imageState.value
? imageState.value.naturalHeight || null
: null
)
</script>
<template>
<ZoomPane
@@ -32,21 +38,9 @@ function onImageLoad() {
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img
ref="imageRef"
:src
v-bind="slotProps"
class="size-full object-contain"
@load="onImageLoad"
/>
<img :src v-bind="slotProps" class="size-full object-contain" />
</ZoomPane>
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="onImageLoad"
/>
<img v-else class="grow object-contain contain-size" :src />
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"

View File

@@ -4,9 +4,24 @@ import { createTestingPinia } from '@pinia/testing'
import { render, screen, fireEvent } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
@@ -77,6 +92,7 @@ describe('ImagePreview', () => {
}
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
vi.clearAllMocks()
})

View File

@@ -71,7 +71,6 @@
draggable="false"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
@@ -166,7 +165,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useImage, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -205,7 +204,6 @@ const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
@@ -232,6 +230,20 @@ const gridCols = computed(() => {
return 4
})
// Use useImage for error detection only. Load handling stays on the rendered
// <img> @load handler so syncLegacyNodeImgs receives the actual DOM element.
const { error: imageError } = useImage(
computed(() => ({ src: currentImageUrl.value, alt: imageAltText.value }))
)
watch(imageError, (err) => {
if (err) {
stopDelayedLoader()
showLoader.value = false
actualDimensions.value = null
}
})
watch(
() => imageUrls,
(newUrls, oldUrls) => {
@@ -248,11 +260,11 @@ watch(
currentIndex.value = 0
}
// Reset loading and error states when URLs change
// Reset loading and dimensions when URLs change. `imageError` is reset
// automatically by `useImage` when the source changes.
actualDimensions.value = null
viewMode.value = defaultViewMode(newUrls)
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
@@ -263,7 +275,6 @@ function handleImageLoad(event: Event) {
const img = event.target
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
@@ -273,13 +284,6 @@ function handleImageLoad(event: Event) {
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
function handleEditMask() {
if (!nodeId) return
const node = resolveNode(Number(nodeId))
@@ -304,7 +308,6 @@ function setCurrentIndex(index: number) {
if (index >= 0 && index < imageUrls.length) {
const urlChanged = imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}

View File

@@ -1,9 +1,32 @@
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const useImageMock = vi.hoisted(() => ({
state: null as Ref<HTMLImageElement | undefined> | null,
isReady: null as Ref<boolean> | null,
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
useImageMock.isReady = ref(false)
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({
state: useImageMock.state,
isReady: useImageMock.isReady,
error: useImageMock.error
})
}
})
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
@@ -21,6 +44,19 @@ const i18n = createI18n({
}
})
function makeFakeLoadedImage(width: number, height: number): HTMLImageElement {
const img = new Image()
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: width
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: height
})
return img
}
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
@@ -43,6 +79,12 @@ describe('LivePreview', () => {
})
}
beforeEach(() => {
useImageMock.state!.value = undefined
useImageMock.isReady!.value = false
useImageMock.error!.value = null
})
it('renders preview when imageUrl provided', () => {
renderLivePreview()
@@ -74,54 +116,62 @@ describe('LivePreview', () => {
it('handles image load event', async () => {
const { container } = renderLivePreview()
const img = screen.getByRole('img')
Object.defineProperty(img, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img, 'naturalHeight', {
writable: false,
value: 512
})
await fireEvent.load(img)
useImageMock.state!.value = makeFakeLoadedImage(512, 512)
useImageMock.isReady!.value = true
await nextTick()
expect(container.textContent).toContain('512 x 512')
})
it('keeps last good dimensions when imageUrl changes (no flicker)', async () => {
const { container, rerender } = renderLivePreview()
useImageMock.state!.value = makeFakeLoadedImage(800, 600)
useImageMock.isReady!.value = true
await nextTick()
expect(container.textContent).toContain('800 x 600')
// Simulate the source changing during live preview streaming. useImage
// would normally reset isReady to false until the next image is ready.
useImageMock.isReady!.value = false
await rerender({
imageUrl: '/api/view?filename=test_sample_2.png&type=temp'
})
await nextTick()
// Dimensions should still display, not flicker back to "Calculating".
expect(container.textContent).toContain('800 x 600')
expect(container.textContent).not.toContain('Calculating dimensions')
})
it('handles image error state', async () => {
renderLivePreview()
const img = screen.getByRole('img')
await fireEvent.error(img)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
screen.getByText('Image failed to load')
})
it('resets state when imageUrl changes', async () => {
it('resets error state when imageUrl changes', async () => {
const { container, rerender } = renderLivePreview()
const img = screen.getByRole('img')
await fireEvent.error(img)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(container.textContent).toContain('Error loading image')
// useImage resets error automatically when src changes.
useImageMock.error!.value = null
await rerender({ imageUrl: '/new-image.png' })
await nextTick()
expect(container.textContent).toContain('Calculating dimensions')
expect(container.textContent).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const { container } = renderLivePreview()
const img = screen.getByRole('img')
await fireEvent.error(img)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.queryByRole('img')).not.toBeInTheDocument()

View File

@@ -12,8 +12,6 @@
:src="imageUrl"
:alt="$t('g.liveSamplingPreview')"
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
@load="handleImageLoad"
@error="handleImageError"
/>
<div class="text-node-component-header-text mt-1 text-center text-xs">
{{
@@ -26,7 +24,9 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useImage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
interface LivePreviewProps {
imageUrl: string
@@ -34,29 +34,36 @@ interface LivePreviewProps {
const props = defineProps<LivePreviewProps>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const { t } = useI18n()
watch(
() => props.imageUrl,
() => {
// Reset error state when URL changes, but keep previous dimensions
// to avoid flickering "Calculating dimensions" text during live preview
imageError.value = false
}
const {
state: imageState,
isReady,
error
} = useImage(
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
)
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
// Cache last successfully loaded dimensions so the placeholder text does not
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
// live preview streaming. Update only when a new image is ready, never on
// URL change alone.
const cachedWidth = ref<number | null>(null)
const cachedHeight = ref<number | null>(null)
const handleImageError = () => {
imageError.value = true
actualDimensions.value = null
}
watch([isReady, imageState], ([ready, img]) => {
if (!ready || !img) return
if (img.naturalWidth && img.naturalHeight) {
cachedWidth.value = img.naturalWidth
cachedHeight.value = img.naturalHeight
}
})
const imageError = computed(() => !!error.value)
const actualDimensions = computed(() =>
cachedWidth.value && cachedHeight.value
? `${cachedWidth.value} x ${cachedHeight.value}`
: null
)
</script>

View File

@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
const useImageMock = vi.hoisted(() => ({
error: null as Ref<unknown> | null
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
const { ref } = await import('vue')
useImageMock.error = ref<unknown>(null)
return {
...(actual as Record<string, unknown>),
useImage: () => ({ error: useImageMock.error })
}
})
import PackBanner from './PackBanner.vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
defaultBanner: 'Default banner'
}
}
}
})
function makePack(
overrides: Partial<components['schemas']['Node']> = {}
): components['schemas']['Node'] {
return {
id: 'pack-id',
name: 'TestPack',
...overrides
} as components['schemas']['Node']
}
function renderPackBanner(nodePack: components['schemas']['Node']) {
return render(PackBanner, {
props: { nodePack },
global: { plugins: [i18n] }
})
}
describe('PackBanner', () => {
beforeEach(() => {
if (useImageMock.error) useImageMock.error.value = null
})
it('renders the default banner when both banner_url and icon are missing', () => {
renderPackBanner(makePack())
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', DEFAULT_BANNER)
expect(img).toHaveAttribute('alt', 'Default banner')
})
it('renders the banner_url image when provided', () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/banner.png' }))
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/banner.png')
expect(img).toHaveAttribute('alt', 'TestPack banner')
})
it('falls back to icon when banner_url is missing but icon is set', () => {
renderPackBanner(makePack({ icon: 'https://example.com/icon.svg' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/icon.svg'
)
})
it('falls back to default banner when image fails to load', async () => {
renderPackBanner(makePack({ banner_url: 'https://example.com/broken.png' }))
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/broken.png'
)
useImageMock.error!.value = new Event('error')
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('src', DEFAULT_BANNER)
})
})

View File

@@ -12,7 +12,7 @@
<div v-else class="relative size-full">
<!-- blur background -->
<div
v-if="imgSrc"
v-if="imgSrc && !isImageError"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
@@ -21,21 +21,24 @@
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="isImageError = true"
v-if="isImageError"
:src="DEFAULT_BANNER"
:alt="bannerAlt"
class="relative z-10 size-full object-cover"
/>
<img
v-else
:src="imgSrc"
:alt="bannerAlt"
class="relative z-10 size-full object-contain"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
@@ -45,8 +48,11 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon || '')
const bannerAlt = computed(() => `${nodePack.name} banner`)
const { error: isImageError } = useImage(
computed(() => ({ src: imgSrc.value, alt: bannerAlt.value }))
)
</script>