mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-07 08:40:19 +01:00
📐 fix: Replace JS Image Scaling with CSS Viewport Constraints (#12089)
* fix: remove scaleImage function that stretched vertical images * chore: lint * refactor: Simplify Image Component Usage Across Chat Parts - Removed height and width props from the Image component in various parts (Files, Part, ImageAttachment, LogContent) to streamline image rendering. - Introduced a constant for maximum image height in the Image component for consistent styling. - Updated related components to utilize the new simplified Image component structure, enhancing maintainability and reducing redundancy. * refactor: Simplify LogContent and Enhance Image Component Tests - Removed height and width properties from the ImageAttachment type in LogContent for cleaner code. - Updated the image rendering logic to rely solely on the filepath, improving clarity. - Enhanced the Image component tests with additional assertions for rendering behavior and accessibility. - Introduced new tests for OpenAIImageGen to validate image preloading and progress handling, ensuring robust functionality. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
771227ecf9
commit
3a73907daa
10 changed files with 416 additions and 183 deletions
|
|
@ -21,15 +21,7 @@ const Files = ({ message }: { message?: TMessage }) => {
|
|||
<Image
|
||||
key={file.file_id}
|
||||
imagePath={file.preview ?? file.filepath ?? ''}
|
||||
height={file.height ?? 1920}
|
||||
width={file.width ?? 1080}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
placeholderDimensions={{
|
||||
height: `${file.height ?? 1920}px`,
|
||||
width: `${file.height ?? 1080}px`,
|
||||
}}
|
||||
// n={imageFiles.length}
|
||||
// i={i}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,26 +2,20 @@ import React, { useState, useRef, useMemo } from 'react';
|
|||
import { Skeleton } from '@librechat/client';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { apiBaseUrl } from 'librechat-data-provider';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
|
||||
/** Max display height for chat images (Tailwind JIT class) */
|
||||
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
altText,
|
||||
height,
|
||||
width,
|
||||
placeholderDimensions,
|
||||
className,
|
||||
args,
|
||||
}: {
|
||||
imagePath: string;
|
||||
altText: string;
|
||||
height: number;
|
||||
width: number;
|
||||
placeholderDimensions?: {
|
||||
height?: string;
|
||||
width?: string;
|
||||
};
|
||||
className?: string;
|
||||
args?: {
|
||||
prompt?: string;
|
||||
|
|
@ -33,7 +27,6 @@ const Image = ({
|
|||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleImageLoad = () => setIsLoaded(true);
|
||||
|
|
@ -56,16 +49,6 @@ const Image = ({
|
|||
return `${baseURL}${imagePath}`;
|
||||
}, [imagePath]);
|
||||
|
||||
const { width: scaledWidth, height: scaledHeight } = useMemo(
|
||||
() =>
|
||||
scaleImage({
|
||||
originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width),
|
||||
originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height),
|
||||
containerRef,
|
||||
}),
|
||||
[placeholderDimensions, height, width],
|
||||
);
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(absoluteImageUrl);
|
||||
|
|
@ -96,7 +79,7 @@ const Image = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
|
|
@ -114,19 +97,14 @@ const Image = ({
|
|||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
'block h-auto w-auto max-w-full text-transparent transition-opacity duration-100',
|
||||
IMAGE_MAX_H,
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={absoluteImageUrl}
|
||||
style={{
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
display: 'block',
|
||||
}}
|
||||
placeholder={
|
||||
<Skeleton
|
||||
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
|
||||
className={cn(IMAGE_MAX_H, 'h-48 w-full max-w-lg')}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -222,19 +222,8 @@ const Part = memo(function Part({
|
|||
}
|
||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||
const height = imageFile.height ?? 1920;
|
||||
const width = imageFile.width ?? 1080;
|
||||
return (
|
||||
<Image
|
||||
imagePath={imageFile.filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||
placeholderDimensions={{
|
||||
height: height + 'px',
|
||||
width: width + 'px',
|
||||
}}
|
||||
/>
|
||||
<Image imagePath={imageFile.filepath} altText={imageFile.filename ?? 'Uploaded Image'} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
|||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
|
|
@ -76,8 +76,6 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
|||
<Image
|
||||
altText={attachment.filename || 'attachment image'}
|
||||
imagePath={filepath ?? ''}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ interface LogContentProps {
|
|||
attachments?: TAttachment[];
|
||||
}
|
||||
|
||||
type ImageAttachment = TFile &
|
||||
TAttachmentMetadata & {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
type ImageAttachment = TFile & TAttachmentMetadata;
|
||||
|
||||
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -35,12 +31,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
const nonImageAtts: TAttachment[] = [];
|
||||
|
||||
attachments?.forEach((attachment) => {
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage =
|
||||
imageExtRegex.test(attachment.filename ?? '') &&
|
||||
width != null &&
|
||||
height != null &&
|
||||
filepath != null;
|
||||
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
|
||||
if (isImage) {
|
||||
imageAtts.push(attachment as ImageAttachment);
|
||||
} else {
|
||||
|
|
@ -100,18 +92,13 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments?.map((attachment, index) => {
|
||||
const { width, height, filepath } = attachment;
|
||||
return (
|
||||
<Image
|
||||
key={index}
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{imageAttachments?.map((attachment) => (
|
||||
<Image
|
||||
key={attachment.filepath}
|
||||
altText={attachment.filename}
|
||||
imagePath={attachment.filepath}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { PixelCard } from '@librechat/client';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import ProgressText from './ProgressText';
|
||||
import { scaleImage } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||
const IMAGE_FULL_H = 'h-[45vh]' as const;
|
||||
|
||||
export default function OpenAIImageGen({
|
||||
initialProgress = 0.1,
|
||||
|
|
@ -28,8 +31,6 @@ export default function OpenAIImageGen({
|
|||
|
||||
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
|
||||
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let quality: 'low' | 'medium' | 'high' = 'high';
|
||||
|
||||
// Parse args if it's a string
|
||||
|
|
@ -41,61 +42,15 @@ export default function OpenAIImageGen({
|
|||
parsedArgs = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const argsObj = parsedArgs;
|
||||
|
||||
if (argsObj && typeof argsObj.size === 'string') {
|
||||
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
width = w;
|
||||
height = h;
|
||||
}
|
||||
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
|
||||
width = undefined;
|
||||
height = undefined;
|
||||
if (parsedArgs && typeof parsedArgs.quality === 'string') {
|
||||
const q = parsedArgs.quality.toLowerCase();
|
||||
if (q === 'low' || q === 'medium' || q === 'high') {
|
||||
quality = q;
|
||||
}
|
||||
|
||||
if (argsObj && typeof argsObj.quality === 'string') {
|
||||
const q = argsObj.quality.toLowerCase();
|
||||
if (q === 'low' || q === 'medium' || q === 'high') {
|
||||
quality = q;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
width = undefined;
|
||||
height = undefined;
|
||||
}
|
||||
|
||||
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
|
||||
const attachment = attachments?.[0];
|
||||
const {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
filepath = null,
|
||||
filename = '',
|
||||
} = (attachment as TFile & TAttachmentMetadata) || {};
|
||||
|
||||
let origWidth = width ?? imageWidth;
|
||||
let origHeight = height ?? imageHeight;
|
||||
|
||||
if (origWidth === undefined || origHeight === undefined) {
|
||||
origWidth = 1024;
|
||||
origHeight = 1024;
|
||||
}
|
||||
|
||||
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
if (origWidth && origHeight && containerRef.current) {
|
||||
const scaled = scaleImage({
|
||||
originalWidth: origWidth,
|
||||
originalHeight: origHeight,
|
||||
containerRef,
|
||||
});
|
||||
setDimensions(scaled);
|
||||
}
|
||||
}, [origWidth, origHeight]);
|
||||
const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
|
|
@ -156,45 +111,19 @@ export default function OpenAIImageGen({
|
|||
}
|
||||
}, [initialProgress, cancelled]);
|
||||
|
||||
useEffect(() => {
|
||||
updateDimensions();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateDimensions();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [updateDimensions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||
</div>
|
||||
<div className="relative mb-2 flex w-full justify-start">
|
||||
<div ref={containerRef} className="w-full max-w-lg">
|
||||
{dimensions.width !== 'auto' && progress < 1 && (
|
||||
<PixelCard
|
||||
variant="default"
|
||||
progress={progress}
|
||||
randomness={0.6}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
)}
|
||||
<div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
|
||||
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
|
||||
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
|
||||
<Image
|
||||
altText={filename}
|
||||
imagePath={filepath ?? ''}
|
||||
width={Number(dimensions.width?.split('px')[0])}
|
||||
height={Number(dimensions.height?.split('px')[0])}
|
||||
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
|
||||
args={parsedArgs}
|
||||
className={progress < 1 ? 'invisible absolute' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Image from '../Image';
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: unknown[]) =>
|
||||
classes
|
||||
.flat(Infinity)
|
||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
||||
.join(' '),
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
apiBaseUrl: () => '',
|
||||
}));
|
||||
|
||||
jest.mock('react-lazy-load-image-component', () => ({
|
||||
LazyLoadImage: ({
|
||||
alt,
|
||||
src,
|
||||
className,
|
||||
onLoad,
|
||||
placeholder,
|
||||
visibleByDefault: _visibleByDefault,
|
||||
...rest
|
||||
}: {
|
||||
alt: string;
|
||||
src: string;
|
||||
className: string;
|
||||
onLoad: () => void;
|
||||
placeholder: React.ReactNode;
|
||||
visibleByDefault?: boolean;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div data-testid="lazy-image-wrapper">
|
||||
<img
|
||||
alt={alt}
|
||||
src={src}
|
||||
className={className}
|
||||
onLoad={onLoad}
|
||||
data-testid="lazy-image"
|
||||
{...rest}
|
||||
/>
|
||||
<div data-testid="placeholder">{placeholder}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div data-testid="skeleton" className={className} {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../DialogImage', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isOpen, src }: { isOpen: boolean; src: string }) =>
|
||||
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
|
||||
}));
|
||||
|
||||
describe('Image', () => {
|
||||
const defaultProps = {
|
||||
imagePath: '/images/test.png',
|
||||
altText: 'Test image',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders with max-h-[45vh] height constraint', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img.className).toContain('max-h-[45vh]');
|
||||
});
|
||||
|
||||
it('renders with max-w-full to prevent landscape clipping', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img.className).toContain('max-w-full');
|
||||
});
|
||||
|
||||
it('renders with w-auto and h-auto for natural aspect ratio', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img.className).toContain('w-auto');
|
||||
expect(img.className).toContain('h-auto');
|
||||
});
|
||||
|
||||
it('starts with opacity-0 before image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img.className).toContain('opacity-0');
|
||||
expect(img.className).not.toContain('opacity-100');
|
||||
});
|
||||
|
||||
it('transitions to opacity-100 after image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
|
||||
fireEvent.load(img);
|
||||
|
||||
expect(img.className).toContain('opacity-100');
|
||||
});
|
||||
|
||||
it('applies custom className to the button wrapper', () => {
|
||||
render(<Image {...defaultProps} className="mb-4" />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.className).toContain('mb-4');
|
||||
});
|
||||
|
||||
it('sets correct alt text', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img).toHaveAttribute('alt', 'Test image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skeleton placeholder', () => {
|
||||
it('renders skeleton with non-zero dimensions', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton.className).toContain('h-48');
|
||||
expect(skeleton.className).toContain('w-full');
|
||||
expect(skeleton.className).toContain('max-w-lg');
|
||||
});
|
||||
|
||||
it('renders skeleton with max-h constraint', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton.className).toContain('max-h-[45vh]');
|
||||
});
|
||||
|
||||
it('has accessible loading attributes', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton).toHaveAttribute('aria-label', 'Loading image');
|
||||
expect(skeleton).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog interaction', () => {
|
||||
it('opens dialog on button click after image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
fireEvent.load(img);
|
||||
|
||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render dialog before image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct accessibility attributes on button', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'View Test image in dialog');
|
||||
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('image URL resolution', () => {
|
||||
it('passes /images/ paths through with base URL', () => {
|
||||
render(<Image {...defaultProps} imagePath="/images/test.png" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img).toHaveAttribute('src', '/images/test.png');
|
||||
});
|
||||
|
||||
it('passes absolute http URLs through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('passes data URIs through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
|
||||
});
|
||||
|
||||
it('passes non-/images/ paths through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="/other/path.png" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img).toHaveAttribute('src', '/other/path.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: unknown[]) =>
|
||||
classes
|
||||
.flat(Infinity)
|
||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
||||
.join(' '),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
altText,
|
||||
imagePath,
|
||||
className,
|
||||
}: {
|
||||
altText: string;
|
||||
imagePath: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="image-component"
|
||||
data-alt={altText}
|
||||
data-src={imagePath}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
PixelCard: ({ progress }: { progress: number }) => (
|
||||
<div data-testid="pixel-card" data-progress={progress} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
|
||||
__esModule: true,
|
||||
default: ({ progress, error }: { progress: number; error: boolean }) => (
|
||||
<div data-testid="progress-text" data-progress={progress} data-error={String(error)} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('OpenAIImageGen', () => {
|
||||
const defaultProps = {
|
||||
initialProgress: 0.1,
|
||||
isSubmitting: true,
|
||||
toolName: 'image_gen_oai',
|
||||
args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}',
|
||||
output: null as string | null,
|
||||
attachments: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('image preloading', () => {
|
||||
it('keeps Image mounted during generation (progress < 1)', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Image with invisible absolute while progress < 1', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||
const image = screen.getByTestId('image-component');
|
||||
expect(image.className).toContain('invisible');
|
||||
expect(image.className).toContain('absolute');
|
||||
});
|
||||
|
||||
it('shows Image without hiding classes when progress >= 1', () => {
|
||||
render(
|
||||
<OpenAIImageGen
|
||||
{...defaultProps}
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
attachments={[
|
||||
{
|
||||
filename: 'cat.png',
|
||||
filepath: '/images/cat.png',
|
||||
conversationId: 'conv1',
|
||||
} as never,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const image = screen.getByTestId('image-component');
|
||||
expect(image.className).not.toContain('invisible');
|
||||
expect(image.className).not.toContain('absolute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PixelCard visibility', () => {
|
||||
it('shows PixelCard when progress < 1', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides PixelCard when progress >= 1', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
|
||||
expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout classes', () => {
|
||||
it('applies max-h-[45vh] to the outer container', () => {
|
||||
const { container } = render(<OpenAIImageGen {...defaultProps} />);
|
||||
const outerDiv = container.querySelector('[class*="max-h-"]');
|
||||
expect(outerDiv?.className).toContain('max-h-[45vh]');
|
||||
});
|
||||
|
||||
it('applies h-[45vh] w-full to inner container during loading', () => {
|
||||
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
|
||||
expect(innerDiv).not.toBeNull();
|
||||
expect(innerDiv?.className).toContain('w-full');
|
||||
});
|
||||
|
||||
it('applies w-auto to inner container when complete', () => {
|
||||
const { container } = render(
|
||||
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
|
||||
);
|
||||
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
|
||||
expect(overflowDiv?.className).toContain('w-auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('args parsing', () => {
|
||||
it('parses quality from args', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} />);
|
||||
expect(screen.getByTestId('progress-text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles invalid JSON args gracefully', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
|
||||
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles object args', () => {
|
||||
render(
|
||||
<OpenAIImageGen
|
||||
{...defaultProps}
|
||||
args={{ prompt: 'a dog', quality: 'low', size: '512x512' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancellation', () => {
|
||||
it('shows error state when output contains error', () => {
|
||||
render(
|
||||
<OpenAIImageGen
|
||||
{...defaultProps}
|
||||
output="Error processing tool call"
|
||||
isSubmitting={false}
|
||||
initialProgress={0.5}
|
||||
/>,
|
||||
);
|
||||
const progressText = screen.getByTestId('progress-text');
|
||||
expect(progressText).toHaveAttribute('data-error', 'true');
|
||||
});
|
||||
|
||||
it('shows cancelled state when not submitting and incomplete', () => {
|
||||
render(<OpenAIImageGen {...defaultProps} isSubmitting={false} initialProgress={0.5} />);
|
||||
const progressText = screen.getByTestId('progress-text');
|
||||
expect(progressText).toHaveAttribute('data-error', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -29,7 +29,6 @@ export * from './share';
|
|||
export * from './timestamps';
|
||||
export { default as cn } from './cn';
|
||||
export { default as logger } from './logger';
|
||||
export { default as scaleImage } from './scaleImage';
|
||||
export { default as getLoginError } from './getLoginError';
|
||||
export { default as cleanupPreset } from './cleanupPreset';
|
||||
export { default as buildDefaultConvo } from './buildDefaultConvo';
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
export default function scaleImage({
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const containerWidth = containerRef.current?.offsetWidth ?? 0;
|
||||
|
||||
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
|
||||
return { width: 'auto', height: 'auto' };
|
||||
}
|
||||
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
const scaledWidth = Math.min(containerWidth, originalWidth);
|
||||
const scaledHeight = scaledWidth / aspectRatio;
|
||||
|
||||
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue