[CB] write unit tests (#3928)

* dbeaver/pro#7563 adds unit tests for core-utils

* removes restore all mocks

* adds eslint rules: now arrow function + no unnamed functions (to improve debug experience)

* fixes test according new eslint rules

* removes arrow function expression eslint

* adds Icon Or Image unit tests

* removes problematic test

* useSMock & sMock + reuses these mocks

* adds StaticImage tests

* adds UploadArea tests

* adds tests for useAdministrationSettings

* un-skips useCombinedHandler

* tries to fix useObjectRef via correct mocking (failed github action)

* adds useErrorDetails tests

* rewrites useObjectRef tests so it works stable

* unskips app-init & getTextFileReadingProcess

* adds useFuzzySearch tests

* adds useSearch tests

* pr fixes after copilot review

* self review fixes

* pr fixes

* introduces user events

* uses get by role id to check accessibility of the components

* fixes redefined focus in body

* removes mocks

* removes mocks 2

* removes mocks 3

* removes repeated test cases
This commit is contained in:
sergeyteleshev
2025-12-12 18:55:50 +08:00
committed by GitHub
parent 435aac2860
commit 5d2c6dc062
35 changed files with 1669 additions and 223 deletions
@@ -6,9 +6,9 @@
* you may not use this file except in compliance with the License.
*/
import { WebSocket } from 'ws'
import { WebSocket } from 'ws';
import '@testing-library/jest-dom/vitest';
// different machine has its own timezones and some tests can fail because of it
process.env.TZ = 'UTC';
globalThis.WebSocket = WebSocket as any
globalThis.WebSocket = WebSocket as any;
+1
View File
@@ -61,6 +61,7 @@
"@dbeaver/react-tests": "workspace:*",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
"@types/react": "^19",
"mobx": "^6",
"react": "^19",
@@ -10,14 +10,6 @@ import { describe, expect, it, vi } from 'vitest';
import { Cell } from './Cell.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
vi.mock('./Containers/Container', () => ({
Container: (props: any) => <div>{props.children}</div>,
}));
@@ -10,14 +10,6 @@ import { expect, describe, it, vi } from 'vitest';
import { ErrorMessage } from './ErrorMessage.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
vi.mock('./localization/useTranslate', () => ({
useTranslate: () => (key: string) => key,
}));
@@ -35,4 +27,9 @@ describe('ErrorMessage', () => {
const { getByText } = renderInApp(<ErrorMessage text="error" />);
await vi.waitFor(() => expect(getByText('error')).toBeInTheDocument());
});
it('should have role="status"', async () => {
const { getByRole } = renderInApp(<ErrorMessage text="error" />);
await vi.waitFor(() => expect(getByRole('status')).toBeInTheDocument());
});
});
@@ -18,7 +18,7 @@ interface Props {
hasDetails?: boolean;
text: string;
className?: string;
onShowDetails?: () => void;
onShowDetails?: () => Promise<void> | void;
}
export const ErrorMessage = observer<Props>(function ErrorMessage({ text, className, hasDetails, onShowDetails }) {
+14 -23
View File
@@ -5,36 +5,27 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { describe, expect, test, vi } from 'vitest';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { Icon } from './Icon.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('@cloudbeaver/core-utils', () => ({
GlobalConstants: {
absoluteUrl: (name: string) => name,
},
}));
describe('Icon', () => {
test('icons.svg#name', () => {
(globalThis as any)._ROOT_URI_ = undefined;
const { getByTestId } = renderInApp(<Icon data-testid="Icon" name="test" />);
expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/icons/icons.svg#test');
});
test('/image.jpg', () => {
(globalThis as any)._ROOT_URI_ = undefined;
const { getByTestId } = renderInApp(<Icon data-testid="Icon" name="/image.jpg" />);
expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/image.jpg');
});
test('{_ROOT_URI_}/icons.svg#name', () => {
beforeAll(() => {
(globalThis as any)._ROOT_URI_ = '/path/';
});
afterAll(() => {
(globalThis as any)._ROOT_URI_ = undefined;
});
test('should render with "icons.svg#name" path', () => {
const { getByTestId } = renderInApp(<Icon data-testid="Icon" name="test" />);
expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/icons/icons.svg#test');
expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/path/icons/icons.svg#test');
});
test('should render with custom absolute path', () => {
const { getByTestId } = renderInApp(<Icon data-testid="Icon" name="/image.jpg" />);
expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/path/image.jpg');
});
});
@@ -0,0 +1,108 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { fireEvent } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { IconOrImage, type IconOrImageProps } from './IconOrImage.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('./Icon.js', () => ({
Icon: vi.fn(({ ...props }) => (
<svg {...props} role="svg">
<use href={`/icons/icons.svg#${props.name}`} />
</svg>
)),
}));
vi.mock('./StaticImage.js', () => ({
StaticImage: vi.fn(({ ...props }) => <img {...props} />),
}));
describe('IconOrImage', () => {
test('should render StaticImage for platform: prefixed icon', () => {
const { getByRole } = renderInApp(<IconOrImage icon="platform:/image.png" />);
expect(getByRole('img')).toHaveAttribute('icon', 'platform:/image.png');
});
test('should render StaticImage for / prefixed icon', () => {
const { getByRole } = renderInApp(<IconOrImage icon="/image.jpg" />);
expect(getByRole('img')).toHaveAttribute('icon', '/image.jpg');
});
test('should render StaticImage for valid URL', () => {
const { getByRole } = renderInApp(<IconOrImage icon="https://example.com/image.png" />);
expect(getByRole('img')).toHaveAttribute('icon', 'https://example.com/image.png');
});
test('should render Icon for regular icon name', () => {
const { getByRole } = renderInApp(<IconOrImage icon="test" />);
const svg = getByRole('svg');
expect(svg).toHaveAttribute('name', 'test');
});
test('should render Icon when svg prop is true even for platform: icon', () => {
const { getByRole } = renderInApp(<IconOrImage icon="platform:/image.png" svg />);
const svg = getByRole('svg');
expect(svg).toHaveAttribute('name', 'platform:/image.png');
});
test('should render Icon when svg prop is true even for URL', () => {
const { getByRole } = renderInApp(<IconOrImage icon="https://example.com/image.png" svg />);
const svg = getByRole('svg');
expect(svg).toHaveAttribute('name', 'https://example.com/image.png');
});
test('should pass all props to StaticImage', () => {
const handleClick = vi.fn();
const props: IconOrImageProps = {
icon: '/image.jpg',
className: 'custom-class',
title: 'Test Title',
width: 24,
onClick: handleClick,
};
const { getByRole } = renderInApp(<IconOrImage {...props} />);
const element = getByRole('img');
expect(element).toHaveClass(props.className!);
expect(element).toHaveAttribute('title', props.title!);
expect(element).toHaveAttribute('width', props.width!.toString());
expect(element).toHaveAttribute('icon', props.icon);
fireEvent.click(element);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('should pass all props to Icon', () => {
const handleClick = vi.fn();
const props: IconOrImageProps = {
icon: 'test',
className: 'custom-class',
viewBox: '0 0 24 24',
width: 24,
onClick: handleClick,
};
const { getByRole } = renderInApp(<IconOrImage {...props} />);
const element = getByRole('svg');
expect(element).toHaveAttribute('name', props.icon);
expect(element).toHaveClass(props.className!);
expect(element).toHaveAttribute('viewBox', props.viewBox!);
expect(element).toHaveAttribute('width', props.width!.toString());
expect(element).toHaveAttribute('height', props.width!.toString());
fireEvent.click(element);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('should use default viewBox for Icon when not provided', () => {
const { getByRole } = renderInApp(<IconOrImage icon="svg" />);
const element = getByRole('svg');
expect(element).toHaveAttribute('viewBox', '0 0 32 32');
});
});
@@ -15,21 +15,14 @@ vi.mock('./IconOrImage', () => ({
IconOrImage: (props: any) => <svg {...props}>{props.children}</svg>,
}));
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
describe('Link', () => {
it('should render link and children correctly', async () => {
const { getByText } = renderInApp(<Link href="#">Test Link</Link>);
const { getByText, getByRole } = renderInApp(<Link href="#">Test Link</Link>);
const linkElement = await vi.waitFor(() => getByText('Test Link'));
expect(linkElement.tagName).toBe('A');
expect(linkElement).toBeInTheDocument();
expect(getByRole('link')).toBe(linkElement);
});
it('should display the indicator icon when indicator is true', () => {
@@ -0,0 +1,98 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { fireEvent } from '@testing-library/react';
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
import { renderInApp } from '@cloudbeaver/tests-runner';
import { StaticImage } from './StaticImage.js';
import * as sModule from './s.js';
describe('StaticImage', () => {
beforeAll(() => {
(globalThis as any)._ROOT_URI_ = '/absolute';
});
afterAll(() => {
(globalThis as any)._ROOT_URI_ = undefined;
});
test('should return null when icon is not provided', () => {
const { container } = renderInApp(<StaticImage />);
expect(container.firstChild).toBeNull();
});
test('should render img element when icon is provided', () => {
const { getByRole } = renderInApp(<StaticImage icon="/image.png" />);
const img = getByRole('img');
expect(img).toBeInTheDocument();
});
test('should use valid URL as-is', () => {
const validUrl = 'https://example.com/image.png';
const { getByRole } = renderInApp(<StaticImage icon={validUrl} />);
const img = getByRole('img');
expect(img).toHaveAttribute('src', validUrl);
});
test('should convert relative path to absolute URL', () => {
const relativePath = '/icons/test.png';
const { getByRole } = renderInApp(<StaticImage icon={relativePath} />);
const img = getByRole('img');
expect(img).toHaveAttribute('src', `/absolute${relativePath}`);
});
test('should pass title prop as alt and title attributes', () => {
const title = 'Test Image';
const { getByRole } = renderInApp(<StaticImage icon="/image.png" title={title} />);
const img = getByRole('img');
expect(img).toHaveAttribute('alt', title);
expect(img).toHaveAttribute('title', title);
});
test('should pass width prop', () => {
const width = 48;
const { container } = renderInApp(<StaticImage icon="/image.png" width={width} />);
const img = container.querySelector('img');
expect(img).toHaveAttribute('width', width.toString());
});
test('should pass className prop', () => {
const className = 'custom-class';
const { getByRole } = renderInApp(<StaticImage icon="/image.png" className={className} />);
const img = getByRole('img');
expect(img).toHaveClass(className);
});
test('should handle onClick event', () => {
const handleClick = vi.fn();
const { getByRole } = renderInApp(<StaticImage icon="/image.png" onClick={handleClick} />);
const img = getByRole('img');
fireEvent.click(img!);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('should pass additional img props', () => {
const { getByRole } = renderInApp(<StaticImage icon="/image.png" id="test-id" loading="lazy" crossOrigin="anonymous" />);
const img = getByRole('img');
expect(img).toHaveAttribute('id', 'test-id');
expect(img).toHaveAttribute('loading', 'lazy');
expect(img).toHaveAttribute('crossOrigin', 'anonymous');
});
test('should apply block class when block prop is true', () => {
const sMock = vi.spyOn(sModule, 's');
renderInApp(<StaticImage icon="/image.png" block />);
expect(sMock).toHaveBeenCalledWith({}, { block: true }, undefined);
});
test('should not apply block class when block prop is false', () => {
const sMock = vi.spyOn(sModule, 's');
renderInApp(<StaticImage icon="/image.png" block={false} />);
expect(sMock).toHaveBeenCalledWith({}, { block: false }, undefined);
});
});
@@ -18,10 +18,6 @@ vi.mock('./localization/useTranslate', () => ({
useTranslate: () => (key: string) => key,
}));
vi.mock('./s', () => ({
s: vi.fn(),
}));
vi.mock('./IconOrImage', () => ({
IconOrImage: (props: any) => <svg {...props} />,
}));
@@ -30,10 +26,6 @@ vi.mock('./Link', () => ({
Link: (props: any) => <a {...props} />,
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
vi.mock('./useErrorDetails', () => ({
useErrorDetails: (exception: Error | null) => ({
message: exception?.message || '',
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@ interface Props {
exception?: Error | null;
className?: string;
multipleRows?: boolean;
onShowDetails?: () => void;
onShowDetails?: () => Promise<void> | void;
}
export const StatusMessage = observer<Props>(function StatusMessage({
@@ -10,14 +10,6 @@ import { describe, expect, it, vi } from 'vitest';
import { Text } from './Text.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
describe('Text Component', () => {
it('renders children correctly', async () => {
const { getByText } = renderInApp(<Text>Hello World</Text>);
@@ -10,14 +10,6 @@ import { describe, expect, it, vi } from 'vitest';
import { TextPlaceholder } from './TextPlaceholder.js';
import { renderInApp } from '@cloudbeaver/tests-runner';
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
describe('TextPlaceholder Component', () => {
it('renders children correctly', async () => {
const { getByText } = renderInApp(<TextPlaceholder>Hello World</TextPlaceholder>);
@@ -14,14 +14,6 @@ vi.mock('./Icon', () => ({
Icon: (props: any) => <svg {...props}>Icon</svg>,
}));
vi.mock('./s', () => ({
s: (...args: any[]) => args.join(' '),
}));
vi.mock('./useS', () => ({
useS: vi.fn(),
}));
describe('TimerIcon', () => {
it('renders correctly with state "play" and interval 30', async () => {
const { getByText, getByTestId } = renderInApp(<TimerIcon state="play" data-testid="timer-icon" interval={30} />);
@@ -0,0 +1,145 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { describe, expect, it, vi } from 'vitest';
import { createRef } from 'react';
import { renderInApp } from '@cloudbeaver/tests-runner';
import { UploadArea } from './UploadArea.js';
function createTestFileList(name = 'test.txt', content = 'content', type = 'text/plain') {
const file = new File([content], name, { type });
const fileList = {
0: file,
length: 1,
item: (index: number) => (index === 0 ? file : null),
*[Symbol.iterator]() {
yield file;
},
} as unknown as FileList;
return { file, fileList };
}
describe('UploadArea', () => {
it('should render children correctly', async () => {
const { getByText } = renderInApp(<UploadArea>Upload File</UploadArea>);
const label = await vi.waitFor(() => getByText('Upload File'));
expect(label).toBeInTheDocument();
expect(label.tagName).toBe('LABEL');
});
it('should render input file', () => {
const { container } = renderInApp(<UploadArea>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'file');
});
it('should apply custom className correctly', async () => {
const { getByText } = renderInApp(<UploadArea className="custom-class">Upload File</UploadArea>);
const label = await vi.waitFor(() => getByText('Upload File'));
expect(label).toHaveClass('custom-class');
});
it('should handle onChange event', async () => {
const handleChange = vi.fn();
const { container, user } = renderInApp(<UploadArea onChange={handleChange}>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const { file } = createTestFileList();
await user.upload(input, file);
expect(handleChange).toHaveBeenCalled();
});
it('should reset input value when reset is true', async () => {
const handleChange = vi.fn();
const { container, user } = renderInApp(
<UploadArea reset onChange={handleChange}>
Upload File
</UploadArea>,
);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const { file } = createTestFileList();
await user.upload(input, file);
expect(handleChange).toHaveBeenCalled();
expect(input.value).toBe('');
});
it('should apply disabled state correctly', () => {
const { container } = renderInApp(<UploadArea disabled>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeDisabled();
});
it('should forward ref correctly', () => {
const ref = createRef<HTMLInputElement>();
renderInApp(<UploadArea ref={ref}>Upload File</UploadArea>);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
expect(ref.current?.type).toBe('file');
});
it('should apply custom id when provided', () => {
const customId = 'custom-upload-id';
const { container } = renderInApp(<UploadArea id={customId}>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const label = container.querySelector('label');
expect(input).toHaveAttribute('id', customId);
expect(label).toHaveAttribute('for', customId);
});
it('should accept multiple files when multiple attribute is set', () => {
const { container } = renderInApp(<UploadArea multiple>Upload Files</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toHaveAttribute('multiple');
});
it('should accept specific file types when accept attribute is set', () => {
const { container } = renderInApp(<UploadArea accept=".jpg,.png">Upload Image</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toHaveAttribute('accept', '.jpg,.png');
});
it('should apply title attribute to label', () => {
const { container } = renderInApp(<UploadArea title="Click to upload">Upload File</UploadArea>);
const label = container.querySelector('label');
expect(label).toHaveAttribute('title', 'Click to upload');
});
it('should set files property when value is provided', () => {
const { fileList } = createTestFileList();
const { container } = renderInApp(<UploadArea value={fileList}>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.files).toBe(fileList);
});
it('should handle async onChange event', async () => {
const handleChange = vi.fn(async () => {
await Promise.resolve();
});
const { container, user } = renderInApp(<UploadArea onChange={handleChange}>Upload File</UploadArea>);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const { file } = createTestFileList();
await user.upload(input, file);
expect(handleChange).toHaveBeenCalled();
vi.useRealTimers();
});
});
@@ -0,0 +1,154 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { afterEach, beforeEach, describe, expect, test, vitest } from 'vitest';
import { renderHook } from '@testing-library/react';
import { EAdminPermission, type ServerConfigResource } from '@cloudbeaver/core-root';
import { useAdministrationSettings } from './useAdministrationSettings.js';
vitest.mock('./ResourcesHooks/useResource', () => ({
useResource: vitest.fn(),
}));
vitest.mock('./usePermission', () => ({
usePermission: vitest.fn(),
}));
vitest.mock('@cloudbeaver/core-root', async importOriginal => {
const actual = (await importOriginal()) as object;
return {
...actual,
ServerConfigResource: vitest.fn(),
};
});
describe('useAdministrationSettings', () => {
let mockUsePermission: ReturnType<typeof vitest.fn>;
let mockUseResource: ReturnType<typeof vitest.fn>;
beforeEach(async () => {
const { usePermission } = await import('./usePermission.js');
const { useResource } = await import('./ResourcesHooks/useResource.js');
mockUsePermission = usePermission as ReturnType<typeof vitest.fn>;
mockUseResource = useResource as ReturnType<typeof vitest.fn>;
});
afterEach(() => {
vitest.clearAllMocks();
});
test('should return credentialsSavingEnabled as true for admin users with admin save enabled', () => {
mockUsePermission.mockReturnValue(true);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: true,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
const { result } = renderHook(() => useAdministrationSettings());
expect(result.current.credentialsSavingEnabled).toBe(true);
});
test('should return credentialsSavingEnabled as false for non-admin users with public save disabled', () => {
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: true,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
const { result } = renderHook(() => useAdministrationSettings());
expect(result.current.credentialsSavingEnabled).toBe(false);
});
test('should return credentialsSavingEnabled as true for non-admin users with public save enabled', () => {
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: true,
publicCredentialsSaveEnabled: true,
} as ServerConfigResource,
});
const { result } = renderHook(() => useAdministrationSettings());
expect(result.current.credentialsSavingEnabled).toBe(true);
});
test('should return credentialsSavingEnabled as true in configuration mode', () => {
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: true,
adminCredentialsSaveEnabled: false,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
const { result } = renderHook(() => useAdministrationSettings());
expect(result.current.credentialsSavingEnabled).toBe(true);
});
test('should return credentialsSavingEnabled as false when admin save is disabled', () => {
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: false,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
const { result } = renderHook(() => useAdministrationSettings());
expect(result.current.credentialsSavingEnabled).toBe(false);
});
test('should call usePermission with EAdminPermission.admin', () => {
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: false,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
renderHook(() => useAdministrationSettings());
expect(mockUsePermission).toHaveBeenCalledWith(EAdminPermission.admin);
});
test('should call useResource with ServerConfigResource', async () => {
const { ServerConfigResource } = await import('@cloudbeaver/core-root');
mockUsePermission.mockReturnValue(false);
mockUseResource.mockReturnValue({
resource: {
configurationMode: false,
adminCredentialsSaveEnabled: false,
publicCredentialsSaveEnabled: false,
} as ServerConfigResource,
});
renderHook(() => useAdministrationSettings());
expect(mockUseResource).toHaveBeenCalledWith(useAdministrationSettings, ServerConfigResource, undefined);
});
});
@@ -10,11 +10,7 @@ import { renderHook } from '@testing-library/react';
import { useCombinedHandler } from './useCombinedHandler.js';
vi.mock('./useObjectRef', () => ({
useObjectRef: vi.fn(value => value),
}));
describe.skip('useCombinedHandler', () => {
describe('useCombinedHandler', () => {
test('should call all provided handlers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
@@ -0,0 +1,262 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { beforeEach, describe, expect, it, vitest } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import * as coreDi from '@cloudbeaver/core-di';
import { DetailsError } from '@cloudbeaver/core-sdk';
import { LoadingError } from '@cloudbeaver/core-utils';
import * as coreUtils from '@cloudbeaver/core-utils';
import { useErrorDetails } from './useErrorDetails.js';
// eslint-disable-next-line @cloudbeaver/no-sync-component-import
import { ErrorDetailsDialog } from './ErrorDetailsDialog/ErrorDetailsDialog.js';
vitest.mock('@cloudbeaver/core-di', () => ({
useService: vitest.fn(),
}));
vitest.mock('./ErrorDetailsDialog/ErrorDetailsDialog.js', () => ({
ErrorDetailsDialog: vitest.fn(),
}));
vitest.mock('./localization/useTranslate', () => ({
useTranslate: () => (key: string) => key,
}));
vitest.mock('@cloudbeaver/core-sdk', () => ({
DetailsError: vitest.fn(Error),
}));
vitest.mock('@cloudbeaver/core-dialogs', () => ({
CommonDialogService: vitest.fn(),
}));
class CommonDialogService {
open = vitest.fn(() => Promise.resolve());
}
const getMocks = () => {
const commonDialogServiceMock = new CommonDialogService();
const errorOfMock = vitest.fn();
vitest.spyOn(coreDi, 'useService').mockImplementation(() => commonDialogServiceMock);
vitest.spyOn(coreUtils, 'errorOf').mockImplementation(errorOfMock);
return {
commonDialogServiceMock,
errorOfMock,
};
};
describe('useErrorDetails', () => {
let mockErrorDetailsDialog: ReturnType<typeof vitest.fn>;
beforeEach(() => {
vitest.clearAllMocks();
mockErrorDetailsDialog = ErrorDetailsDialog as unknown as ReturnType<typeof vitest.fn>;
});
it('should return null values when error is null', () => {
const { errorOfMock } = getMocks();
errorOfMock.mockReturnValue(undefined);
const { result } = renderHook(() => useErrorDetails(null));
expect(result.current.error).toBeNull();
expect(result.current.name).toBeUndefined();
expect(result.current.message).toBeUndefined();
expect(result.current.details).toBeUndefined();
expect(result.current.hasDetails).toBe(false);
expect(result.current.isOpen).toBe(false);
expect(result.current.refresh).toBeUndefined();
});
it('should handle string error', () => {
const { errorOfMock } = getMocks();
const errorMessage = 'Something went wrong';
errorOfMock.mockReturnValue(undefined);
const { result } = renderHook(() => useErrorDetails(errorMessage));
expect(result.current.error).toBe(errorMessage);
expect(result.current.name).toBe('core_blocks_exception_message_error_message');
expect(result.current.message).toBe(errorMessage);
expect(result.current.details).toBeUndefined();
expect(result.current.hasDetails).toBe(false);
expect(result.current.isOpen).toBe(false);
});
it('should handle Error object', () => {
const { errorOfMock } = getMocks();
const error = new Error('Test error');
errorOfMock.mockReturnValue(undefined);
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.error).toBe(error);
expect(result.current.name).toBe('Error');
expect(result.current.message).toBe('Test error');
expect(result.current.details).toBeUndefined();
expect(result.current.hasDetails).toBe(false);
expect(result.current.isOpen).toBe(false);
});
it('should extract DetailsError and check hasDetails', () => {
const { errorOfMock } = getMocks();
const detailsError = new DetailsError('Error with details');
detailsError.hasDetails = vitest.fn().mockReturnValue(true);
const error = new Error('Test error');
errorOfMock.mockImplementation((err, errorType) => {
if (errorType === DetailsError) {
return detailsError;
}
return undefined;
});
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.error).toBe(error);
expect(result.current.details).toBe(detailsError);
expect(result.current.hasDetails).toBe(true);
expect(detailsError.hasDetails).toHaveBeenCalled();
});
it('should extract LoadingError and provide refresh function', () => {
const { errorOfMock } = getMocks();
const refreshMock = vitest.fn();
const loadingError = new LoadingError(refreshMock);
const error = new Error('Test error');
errorOfMock.mockImplementation((err, errorType) => {
if (errorType === LoadingError) {
return loadingError;
}
return undefined;
});
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.error).toBe(error);
expect(result.current.refresh).toBe(loadingError.refresh);
});
it('should open error details dialog when open is called', async () => {
const { errorOfMock, commonDialogServiceMock } = getMocks();
const error = new Error('Test error');
errorOfMock.mockReturnValue(undefined);
commonDialogServiceMock.open.mockResolvedValue(undefined);
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.isOpen).toBe(false);
await result.current.open();
expect(commonDialogServiceMock.open).toHaveBeenCalledWith(mockErrorDetailsDialog, error);
expect(result.current.isOpen).toBe(false);
});
it('should set isOpen to true while dialog is open', async () => {
const { errorOfMock, commonDialogServiceMock } = getMocks();
const error = new Error('Test error');
errorOfMock.mockReturnValue(undefined);
let resolveDialog: () => void;
const dialogPromise = new Promise<void>(resolve => {
resolveDialog = resolve;
});
commonDialogServiceMock.open.mockReturnValue(dialogPromise);
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.isOpen).toBe(false);
const openPromise = result.current.open();
await waitFor(() => {
expect(result.current.isOpen).toBe(true);
});
expect(commonDialogServiceMock.open).toHaveBeenCalledWith(mockErrorDetailsDialog, error);
resolveDialog!();
await openPromise;
await waitFor(() => {
expect(result.current.isOpen).toBe(false);
});
});
it('should not open dialog when error is null', async () => {
const { errorOfMock, commonDialogServiceMock } = getMocks();
errorOfMock.mockReturnValue(undefined);
const { result } = renderHook(() => useErrorDetails(null));
await result.current.open();
expect(commonDialogServiceMock.open).not.toHaveBeenCalled();
expect(result.current.isOpen).toBe(false);
});
it('should handle both DetailsError and LoadingError', () => {
const { errorOfMock } = getMocks();
const detailsError = new DetailsError('Error with details');
detailsError.hasDetails = vitest.fn().mockReturnValue(true);
const refreshMock = vitest.fn();
const loadingError = new LoadingError(refreshMock);
const error = new Error('Test error');
errorOfMock.mockImplementation((err, errorType) => {
if (errorType === DetailsError) {
return detailsError;
}
if (errorType === LoadingError) {
return loadingError;
}
return undefined;
});
const { result } = renderHook(() => useErrorDetails(error));
expect(result.current.details).toBe(detailsError);
expect(result.current.hasDetails).toBe(true);
expect(result.current.refresh).toBe(loadingError.refresh);
});
it('should reset isOpen to false even if dialog throws error', async () => {
const { errorOfMock, commonDialogServiceMock } = getMocks();
const error = new Error('Test error');
errorOfMock.mockReturnValue(undefined);
const dialogError = new Error('Dialog error');
commonDialogServiceMock.open.mockRejectedValue(dialogError);
const { result } = renderHook(() => useErrorDetails(error));
await expect(result.current.open()).rejects.toThrow('Dialog error');
expect(result.current.isOpen).toBe(false);
});
});
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@ interface IErrorDetailsHook {
details?: DetailsError;
hasDetails: boolean;
isOpen: boolean;
open: () => void;
open: () => Promise<void>;
refresh?: () => void;
}
@@ -11,12 +11,6 @@ import { renderHook } from '@testing-library/react';
import type { IExecutorHandler, IExecutorHandlersCollection } from '@cloudbeaver/core-executor';
import { useExecutor } from './useExecutor.js';
import { useObjectRef } from './useObjectRef.js';
vitest.mock('@cloudbeaver/core-executor', () => ({}));
vitest.mock('./useObjectRef', () => ({
useObjectRef: vitest.fn(obj => obj),
}));
describe('useExecutor', () => {
let mockExecutor: IExecutorHandlersCollection<any>;
@@ -119,15 +113,4 @@ describe('useExecutor', () => {
expect(mockExecutor.addHandler).not.toHaveBeenCalled();
expect(mockExecutor.addPostHandler).not.toHaveBeenCalled();
});
test('should use useObjectRef', () => {
const options = {
executor: mockExecutor,
handlers: [vitest.fn()],
};
renderHook(() => useExecutor(options));
expect(useObjectRef).toHaveBeenCalledWith(options);
});
});
@@ -0,0 +1,192 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { useFuzzySearch } from './useFuzzySearch.js';
interface TestItem {
name: string;
description: string;
category: string;
}
const mockSearchResults = vi.fn();
const mockIsIndexing = vi.fn();
const mockRemoveAll = vi.fn();
const mockAddAll = vi.fn();
const mockSearch = vi.fn();
const mockClearSearch = vi.fn();
vi.mock('react-minisearch', () => ({
useMiniSearch: vi.fn(() => ({
searchResults: mockSearchResults(),
isIndexing: mockIsIndexing(),
removeAll: mockRemoveAll,
addAll: mockAddAll,
search: mockSearch,
clearSearch: mockClearSearch,
})),
}));
describe('useFuzzySearch', () => {
const testData: TestItem[] = [
{ name: 'Apple', description: 'A red fruit', category: 'fruit' },
{ name: 'Apricot', description: 'A sweet fruit', category: 'fruit' },
{ name: 'Carrot', description: 'An orange vegetable', category: 'vegetable' },
];
beforeEach(() => {
mockSearchResults.mockClear();
mockIsIndexing.mockClear();
mockRemoveAll.mockClear();
mockAddAll.mockClear();
mockSearch.mockClear();
mockClearSearch.mockClear();
mockSearchResults.mockReturnValue(null);
mockIsIndexing.mockReturnValue(false);
});
test('should initialize with empty search results', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name', 'description'],
}),
);
expect(result.current.searchResult).toBeNull();
expect(result.current.isIndexing).toBe(false);
});
test('should call search method with correct query', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name'],
}),
);
result.current.search('Apple');
expect(mockSearch).toHaveBeenCalledWith('Apple');
expect(mockSearch).toHaveBeenCalledTimes(1);
});
test('should support searching across multiple fields', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name', 'description'],
}),
);
result.current.search('orange');
expect(mockSearch).toHaveBeenCalledWith('orange');
});
test('should call search with prefix query', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name'],
prefix: true,
}),
);
result.current.search('Ap');
expect(mockSearch).toHaveBeenCalledWith('Ap');
});
test('should call clearSearch method', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name'],
}),
);
result.current.search('Apple');
result.current.clearSearch();
expect(mockClearSearch).toHaveBeenCalled();
expect(mockClearSearch).toHaveBeenCalledTimes(1);
});
test('should call removeAll and addAll when source proposals change', () => {
const { rerender } = renderHook(
({ proposals }) =>
useFuzzySearch({
sourceProposals: proposals,
fields: ['name'],
}),
{
initialProps: {
proposals: testData,
},
},
);
mockRemoveAll.mockClear();
mockAddAll.mockClear();
const newData: TestItem[] = [...testData, { name: 'Grape', description: 'A purple fruit', category: 'fruit' }];
rerender({ proposals: newData });
expect(mockRemoveAll).toHaveBeenCalled();
expect(mockAddAll).toHaveBeenCalled();
});
test('should handle empty source proposals', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: [],
fields: ['name'],
}),
);
result.current.search('test');
expect(mockSearch).toHaveBeenCalledWith('test');
});
test('should handle search with empty query', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name'],
}),
);
result.current.search('');
expect(mockSearch).toHaveBeenCalledWith('');
});
test('should use useObservableRef to make state observable', () => {
const { result } = renderHook(() =>
useFuzzySearch({
sourceProposals: testData,
fields: ['name', 'description'],
}),
);
// Verify the returned state has all required properties
expect(result.current).toHaveProperty('searchResult');
expect(result.current).toHaveProperty('isIndexing');
expect(result.current).toHaveProperty('removeAll');
expect(result.current).toHaveProperty('addAll');
expect(result.current).toHaveProperty('search');
expect(result.current).toHaveProperty('clearSearch');
});
});
@@ -6,118 +6,155 @@
* you may not use this file except in compliance with the License.
*/
import { describe, expect, test, vitest } from 'vitest';
import { describe, expect, test } from 'vitest';
import { renderHook } from '@testing-library/react';
import * as coreUtils from '@cloudbeaver/core-utils';
import { useObjectRef } from './useObjectRef.js';
vitest.mock('@cloudbeaver/core-utils', () => ({
bindFunctions: vitest.fn(),
}));
interface ITestObject {
value: number;
text?: string;
getValue(): number;
setValue(value: number): void;
}
function createTestObject(): ITestObject {
return {
value: 1,
text: 'initial',
getValue() {
return this.value;
},
setValue(value: number) {
this.value = value;
},
};
}
describe('useObjectRef', () => {
test('should initialize', () => {
const { result } = renderHook(() =>
useObjectRef({
count: 0,
increment: function (this: { count: number }) {
this.count++;
},
}),
test('returns stable object reference between renders', () => {
const { result, rerender } = renderHook(() => useObjectRef(createTestObject));
const firstRef = result.current;
rerender();
const secondRef = result.current;
expect(firstRef).toBe(secondRef);
});
test('initializes from object literal and keeps same reference', () => {
const initObject: ITestObject = createTestObject();
const { result, rerender } = renderHook(() => useObjectRef(initObject));
const firstRef = result.current;
rerender();
expect(result.current).toBe(firstRef);
// internal state should keep same reference between renders
expect(result.current.value).toBe(1);
});
test('applies update object on each render', () => {
const { result, rerender } = renderHook(
({ value }: { value: number }) =>
useObjectRef(
() => ({
value: 0,
}),
{
value,
},
),
{
initialProps: { value: 1 },
},
);
expect(result.current.count).toBe(0);
expect(typeof result.current.increment).toBe('function');
expect(result.current.value).toBe(1);
rerender({ value: 5 });
expect(result.current.value).toBe(5);
});
test('should initialize with empty object', () => {
const { result } = renderHook(() => useObjectRef({}));
test('binds specified methods to object when initialized', () => {
const { result } = renderHook(() => useObjectRef(createTestObject, false, ['getValue', 'setValue']));
expect(result.current).toEqual({});
const ref = result.current;
const getValue = ref.getValue;
const setValue = ref.setValue;
// if binding works, `this` inside methods should point to the ref object
expect(getValue()).toBe(1);
setValue(10);
expect(ref.value).toBe(10);
expect(getValue()).toBe(10);
});
test('should bind ref functions', () => {
const bindFunctions = vitest.spyOn(coreUtils, 'bindFunctions');
test('binds methods when using bind array as second arg', () => {
const { result } = renderHook(() => useObjectRef(createTestObject, ['getValue', 'setValue']));
renderHook(() =>
useObjectRef(
() => ({
count: 0,
increment: function (this: { count: number }) {
this.count++;
},
}),
false,
['increment'],
),
);
const ref = result.current;
const getValue = ref.getValue;
const setValue = ref.setValue;
expect(bindFunctions).toHaveBeenCalledTimes(1);
bindFunctions.mockClear();
expect(getValue()).toBe(1);
setValue(3);
expect(getValue()).toBe(3);
});
test('should merge update to bind', () => {
const bindFunctions = vitest.spyOn(coreUtils, 'bindFunctions');
renderHook(() =>
useObjectRef(
() => ({
count: 0,
increment: function (this: { count: number }) {
this.count++;
},
}),
{
count: 0,
},
['increment'],
),
);
expect(bindFunctions).toHaveBeenCalledTimes(1);
bindFunctions.mockClear();
});
test('should update ref via initial state method', () => {
type TRef = { update: { count: number; increment?: (this: { count: number }) => void } };
const init = () => ({ count: 0 });
const { result } = renderHook(({ update }: TRef) => useObjectRef(init, update, ['increment']), {
initialProps: {
update: {
count: 0,
increment: function () {
this.count++;
},
},
test('rebinds only methods present in update object', () => {
const create = () => ({
value: 1,
other: 1,
getValue() {
return this.value;
},
getOther() {
return this.other;
},
});
expect(result.current.count).toBe(0);
const { result, rerender } = renderHook(
({ value }: { value: number }) =>
useObjectRef(
create,
{
value,
},
['getValue', 'getOther'],
),
{ initialProps: { value: 1 } },
);
result.current?.increment?.();
const ref = result.current;
const getValue = ref.getValue;
const getOther = ref.getOther;
expect(result.current.count).toBe(1);
expect(getValue()).toBe(1);
expect(getOther()).toBe(1);
rerender({ value: 10 });
// getValue should be rebound to updated "value", getOther should still work with old binding
expect(ref.value).toBe(10);
expect(getValue()).toBe(10);
expect(getOther()).toBe(1);
});
test('should update ref', () => {
type TRef = { update: { count: number } };
const init = () => ({ count: 0 });
test('supports partial init and update', () => {
const { result, rerender } = renderHook(
({ text }: { text: string }) => useObjectRef<ITestObject, Partial<ITestObject>>(() => ({ value: 1 }) as ITestObject, { text }),
{ initialProps: { text: 'first' } },
);
const { result, rerender } = renderHook(({ update }: TRef) => useObjectRef(init, update), {
initialProps: {
update: {
count: 0,
},
} as TRef,
});
expect(result.current.value).toBe(1);
expect(result.current.text).toBe('first');
expect(result.current.count).toBe(0);
rerender({ text: 'second' });
rerender({ update: { count: 3 } });
expect(result.current.count).toBe(3);
expect(result.current.text).toBe('second');
});
});
@@ -8,16 +8,10 @@
import { describe, expect, test, vitest } from 'vitest';
import { renderHook } from '@testing-library/react';
import { action, computed, isObservable, observable, runInAction } from 'mobx';
import { computed, isObservable, observable, runInAction } from 'mobx';
import * as coreUtils from '@cloudbeaver/core-utils';
import { useObservableRef } from './useObservableRef.js';
vitest.mock('@cloudbeaver/core-utils', () => ({
bindFunctions: vitest.fn(),
}));
describe('useObservableRef', () => {
test('should initialize with a function', () => {
const init = () => ({ count: 0 });
@@ -42,7 +36,7 @@ describe('useObservableRef', () => {
test('should bind functions', () => {
const bindFunctions = vitest.spyOn(coreUtils, 'bindFunctions');
const observed = { count: observable, increment: action };
const observed = { count: observable };
renderHook(() =>
useObservableRef(
@@ -102,7 +96,7 @@ describe('useObservableRef', () => {
this.count++;
},
});
const observed = { count: observable, increment: action };
const observed = { count: observable };
const update = ['increment'];
renderHook(() => useObservableRef(init, observed, update));
@@ -0,0 +1,304 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { useSearch } from './useSearch.js';
interface TestItem {
name: string;
description: string;
category: string;
}
const useFuzzySearchSpy = vi.fn();
const mockFuzzySearch = vi.fn();
let mockSearchResult: any = null;
vi.mock('./useFuzzySearch', () => ({
useFuzzySearch: vi.fn((config: any) => {
useFuzzySearchSpy(config);
return {
search: mockFuzzySearch,
get searchResult() {
return mockSearchResult;
},
};
}),
}));
describe('useSearch', () => {
const testData: TestItem[] = [
{ name: 'Apple', description: 'A red fruit', category: 'fruit' },
{ name: 'Apricot', description: 'A sweet fruit', category: 'fruit' },
{ name: 'Carrot', description: 'An orange vegetable', category: 'vegetable' },
{ name: 'Banana', description: 'A yellow fruit', category: 'fruit' },
{ name: 'Avocado', description: 'A green fruit', category: 'fruit' },
{ name: 'Pineapple', description: 'A tropical fruit', category: 'fruit' },
];
beforeEach(() => {
useFuzzySearchSpy.mockClear();
mockFuzzySearch.mockClear();
mockSearchResult = null;
});
describe('Initialization', () => {
test('should initialize with correct default values and observed state', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name', 'description'],
}),
);
expect(result.current.searchResult).toEqual([]);
});
test('should initialize fuzzy search when matchStrategy is fuzzy', () => {
renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'fuzzy',
}),
);
expect(useFuzzySearchSpy).toHaveBeenCalledWith({
sourceProposals: testData,
fields: ['name'],
});
});
test('should use contains strategy by default', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
}),
);
result.current.setSearch('app');
expect(result.current.searchResult).toHaveLength(2);
expect(result.current.searchResult.map(item => item.name)).toContain('Apple');
expect(result.current.searchResult.map(item => item.name)).toContain('Pineapple');
});
});
describe('Search Strategy: startsWith', () => {
test('should filter items that start with search term', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'startsWith',
}),
);
result.current.setSearch('Ap');
expect(result.current.searchResult).toHaveLength(2);
expect(result.current.searchResult.map(item => item.name)).toContain('Apple');
expect(result.current.searchResult.map(item => item.name)).toContain('Apricot');
});
test('should be case insensitive', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'startsWith',
}),
);
result.current.setSearch('ap');
expect(result.current.searchResult).toHaveLength(2);
});
test('should not match items that do not start with search term', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'startsWith',
}),
);
result.current.setSearch('na');
expect(result.current.searchResult).toHaveLength(0);
});
});
describe('Search Strategy: contains', () => {
test('should filter items that contain search term', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('an');
expect(result.current.searchResult).toHaveLength(1);
expect(result.current.searchResult[0]!.name).toBe('Banana');
});
test('should search in multiple fields', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name', 'description'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('orange');
expect(result.current.searchResult).toHaveLength(1);
expect(result.current.searchResult[0]!.name).toBe('Carrot');
});
test('should be case insensitive', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['description'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('FRUIT');
expect(result.current.searchResult.length).toBeGreaterThan(0);
});
});
describe('Search Strategy: fuzzy', () => {
test('should call fuzzy search on setSearch', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'fuzzy',
}),
);
result.current.setSearch('aple');
expect(mockFuzzySearch).toHaveBeenCalledWith('aple');
});
});
describe('Predicate Function', () => {
test('should filter results using custom predicate', () => {
const predicate = (item: TestItem) => item.category === 'fruit';
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
predicate,
}),
);
result.current.setSearch('a');
const fruitItems = result.current.searchResult.filter(item => item.category === 'fruit');
expect(fruitItems).toEqual(result.current.searchResult);
});
test('should exclude items when predicate returns false', () => {
const predicate = () => false;
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
predicate,
}),
);
result.current.setSearch('a');
expect(result.current.searchResult).toHaveLength(0);
});
});
describe('Edge Cases', () => {
test('should handle empty search string', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('');
expect(result.current.searchResult).toEqual([]);
});
test('should handle empty source hints', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: [],
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('test');
expect(result.current.searchResult).toEqual([]);
});
test('should exclude exact matches', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('Apple');
expect(result.current.searchResult.map(item => item.name)).not.toContain('Apple');
});
test('should handle non-string field values', () => {
const dataWithMixedTypes = [
{ name: 'Test', description: 'A test item', category: 'test' },
{ name: 123 as any, description: 'Number name', category: 'test' },
{ name: null as any, description: 'Null name', category: 'test' },
];
const { result } = renderHook(() =>
useSearch({
sourceHints: dataWithMixedTypes,
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('tes');
expect(result.current.searchResult).toHaveLength(1);
expect(result.current.searchResult[0]!.name).toBe('Test');
});
test('should handle search term with different casing for exact match', () => {
const { result } = renderHook(() =>
useSearch({
sourceHints: testData,
searchFields: ['name'],
matchStrategy: 'contains',
}),
);
result.current.setSearch('apple');
expect(result.current.searchResult.map(item => item.name.toLowerCase())).not.toContain('apple');
});
});
});
@@ -12,7 +12,7 @@ import { TestBootstrap } from './TestBootstrap.js';
import { TestService } from './TestService.js';
import testModule from './module.js';
test.skip('App Initialization', async () => {
test('App Initialization', async () => {
const app = new App([testModule]);
await (app as any).registerServices();
@@ -21,12 +21,12 @@ describe('copyToClipboard', () => {
});
it('should focus on active element after copy', () => {
document.body.focus = vitest.fn();
const focusSpy = vitest.spyOn(document.body, 'focus');
copyToClipboard('test');
expect(document.activeElement).toBe(document.body);
expect(document.body.focus).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
});
it('should have no children after copy', () => {
@@ -13,7 +13,7 @@ import { isImageBroken } from './isBrokenImage.js';
export type { Options as IScreenshotOptions };
const BROKEN_IMAGE_ERROR_MESSAGE = 'Something went wrong. please try select another file format';
const BROKEN_IMAGE_ERROR_MESSAGE = 'Something went wrong. Please try to select another file format';
export async function downloadSvg<T extends Node>(element: T, options: Options, fileName: string): Promise<void> {
const svg = await modernScreenshot.domToForeignObjectSvg<T>(element, options);
@@ -5,20 +5,20 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vitest, type MockInstance } from 'vitest';
import { consoleSpy, addKnownError } from '@cloudbeaver/tests-runner';
import { afterEach, beforeEach, describe, expect, it, vitest, type MockInstance } from 'vitest';
import { addKnownError, initKnownConsoleMessages } from '@cloudbeaver/tests-runner';
import { getTextFileReadingProcess } from './getTextFileReadingProcess.js';
beforeAll(() => {
addKnownError(/Error: Read error/);
});
describe('getTextFileReadingProcess', () => {
let file: File;
let mockFileReader: Partial<FileReader>;
let fileReaderMock: MockInstance<(this: FileReader) => FileReader>;
initKnownConsoleMessages(() => {
addKnownError(/Error: Read error/);
});
beforeEach(() => {
file = new File(['file content'], 'test.txt', { type: 'text/plain' });
@@ -68,7 +68,8 @@ describe('getTextFileReadingProcess', () => {
}
});
it.skip('should reject with an error if reading fails', async () => {
it('should reject with an error if reading fails', async () => {
const { consoleSpy } = await import('@cloudbeaver/tests-runner');
const { promise } = getTextFileReadingProcess(file);
const error = new Error('Read error');
@@ -0,0 +1,77 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isImageBroken } from './isBrokenImage.js';
describe('isImageBroken', () => {
const OriginalImage = globalThis.Image;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
if (OriginalImage) {
globalThis.Image = OriginalImage;
} else {
delete (globalThis as Record<string, unknown>)['Image'];
}
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should return false if image loads successfully', async () => {
const srcSpy = vi.fn();
class MockImage {
public onload: (() => void) | null = null;
public onerror: (() => void) | null = null;
set src(value: string) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const thisRef = this;
srcSpy(value);
setTimeout(() => {
thisRef.onload?.();
}, 0);
vi.runAllTimers();
}
}
vi.stubGlobal('Image', MockImage);
await expect(isImageBroken('https://example.com/ok.png')).resolves.toBe(false);
expect(srcSpy).toHaveBeenCalledWith('https://example.com/ok.png');
});
it('should return true if image loading fails', async () => {
const srcSpy = vi.fn();
class MockImage {
public onload: (() => void) | null = null;
public onerror: (() => void) | null = null;
set src(value: string) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const thisRef = this;
srcSpy(value);
setTimeout(() => {
thisRef.onerror?.();
}, 0);
vi.runAllTimers();
}
}
vi.stubGlobal('Image', MockImage);
await expect(isImageBroken('https://example.com/broken.png')).resolves.toBe(true);
expect(srcSpy).toHaveBeenCalledWith('https://example.com/broken.png');
});
});
@@ -0,0 +1,92 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { describe, expect, it } from 'vitest';
import { isIp } from './isIp.js';
describe('isIp', () => {
it('should return true for correct IPv4', () => {
expect(isIp('127.0.0.1')).toBe(true);
expect(isIp('192.168.1.100')).toBe(true);
expect(isIp('0.0.0.0')).toBe(true);
expect(isIp('255.255.255.255')).toBe(true);
expect(isIp('10.0.0.1')).toBe(true);
expect(isIp('172.16.0.1')).toBe(true);
expect(isIp('8.8.8.8')).toBe(true);
expect(isIp('1.1.1.1')).toBe(true);
});
it('should return false for incorrect IPv4', () => {
expect(isIp('256.0.0.1')).toBe(false);
expect(isIp('192.168.1')).toBe(false);
expect(isIp('192.168.1.256')).toBe(false);
expect(isIp('192.168.-1.1')).toBe(false);
expect(isIp('192.168.1.1.1')).toBe(false);
expect(isIp('192.168.a.1')).toBe(false);
expect(isIp('999.999.999.999')).toBe(false);
expect(isIp('192.168.1.')).toBe(false);
expect(isIp('.192.168.1.1')).toBe(false);
});
it('should return true for correct IPv6 with and without brackets', () => {
expect(isIp('[2001:0db8::1]')).toBe(true);
expect(isIp('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true);
expect(isIp('::1')).toBe(true);
expect(isIp('[::]')).toBe(true);
expect(isIp('[::1]')).toBe(true);
expect(isIp('fe80::1')).toBe(true);
expect(isIp('[fe80::1]')).toBe(true);
expect(isIp('2001:db8::8a2e:370:7334')).toBe(true);
expect(isIp('[2001:db8::8a2e:370:7334]')).toBe(true);
expect(isIp('::ffff:192.0.2.1')).toBe(true);
expect(isIp('2001:db8::')).toBe(true);
expect(isIp('::2001:db8')).toBe(true);
});
it('should return false for incorrect IPv6 or host', () => {
expect(isIp('[2001:::1::]')).toBe(false);
expect(isIp('example.com')).toBe(false);
expect(isIp('')).toBe(false);
expect(isIp('::::')).toBe(false);
expect(isIp('2001:db8::g')).toBe(false);
expect(isIp('[2001:db8::2')).toBe(false);
expect(isIp('2001:db8::2]')).toBe(false);
expect(isIp('2001:db8::2[')).toBe(false);
expect(isIp('gggg::1')).toBe(false);
expect(isIp('2001:db8:85a3::8a2e:370k:7334')).toBe(false);
expect(isIp('[2001:db8:::]')).toBe(false);
expect(isIp('2001:db8:::1')).toBe(false);
});
it('should handle surrounding whitespace gracefully', () => {
expect(isIp(' 10.0.0.5 ')).toBe(true);
expect(isIp('\t[2001:db8::2]\n')).toBe(true);
expect(isIp(' 192.168.1.1 ')).toBe(true);
expect(isIp('\n::1\t')).toBe(true);
});
it('should return false for non-IP strings', () => {
expect(isIp('localhost')).toBe(false);
expect(isIp('hostname')).toBe(false);
expect(isIp('example.com')).toBe(false);
expect(isIp('www.example.com')).toBe(false);
expect(isIp('not-an-ip')).toBe(false);
expect(isIp('123')).toBe(false);
expect(isIp('abc.def.ghi.jkl')).toBe(false);
});
it('should return false for edge cases and special inputs', () => {
expect(isIp('')).toBe(false);
expect(isIp(' ')).toBe(false);
expect(isIp('null')).toBe(false);
expect(isIp('undefined')).toBe(false);
expect(isIp('...')).toBe(false);
expect(isIp(':::')).toBe(false);
});
});
+46 -5
View File
@@ -6,12 +6,53 @@
* you may not use this file except in compliance with the License.
*/
const IPV4_REGEX = /^(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)$/;
export function isIp(host: string): boolean {
// Matches IPv4 like 127.0.0.1
const ipv4 = /^(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)$/;
if (!host) {
return false;
}
// Matches IPv6 like [2001:0db8::1]
const ipv6 = /^\[?([a-f0-9:]+)\]?$/i;
const trimmedHost = host.trim();
return ipv4.test(host) || ipv6.test(host);
return isIPv4(trimmedHost) || isIPv6(trimmedHost);
}
function isIPv4(host: string): boolean {
return IPV4_REGEX.test(host);
}
function isIPv6(host: string): boolean {
const candidate = normalizeIPv6(host);
if (!candidate) {
return false;
}
try {
const url = new URL(`http://${candidate}`);
return url.hostname.startsWith('[') && url.hostname.endsWith(']');
} catch {
return false;
}
}
function normalizeIPv6(host: string): string | null {
const startsWithBracket = host.startsWith('[');
const endsWithBracket = host.endsWith(']');
if (startsWithBracket !== endsWithBracket) {
return null;
}
if (startsWithBracket) {
return host;
}
if (host.includes('[') || host.includes(']')) {
return null;
}
return `[${host}]`;
}
+1
View File
@@ -60,6 +60,7 @@ export default defineConfig(
'no-inner-declarations': 'off',
'no-constant-condition': 'off',
'space-before-blocks': 'error',
'func-names': ['error', 'always'],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unsafe-finally': 'error',
'require-await': 'error',
@@ -22,6 +22,7 @@
"@testing-library/dom": "^10",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
"mobx": "^6",
"react": "^19",
"react-dom": "^19",
@@ -11,6 +11,8 @@ import { Suspense } from 'react';
import { type IServiceProvider, ServiceProvider } from '@cloudbeaver/core-di';
import { userEvent, type UserEvent } from '@testing-library/user-event';
import type { IApplication } from './createApp.js';
function resetDocument() {
@@ -27,6 +29,12 @@ function ApplicationWrapper(serviceInjector: IServiceProvider): React.FC<React.P
);
}
type App<
Q extends Queries = typeof queries,
Container extends Element | DocumentFragment = HTMLElement,
BaseElement extends Element | DocumentFragment = Container,
> = RenderResult<Q, Container, BaseElement> & { user: ReturnType<UserEvent['setup']> };
// TODO move it to the common-react/@dbeaver/react-tests packages
export function renderInApp<
Q extends Queries = typeof queries,
@@ -36,11 +44,19 @@ export function renderInApp<
ui: React.ReactElement,
options: Omit<RenderOptions<Q, Container, BaseElement>, 'queries' | 'wrapper'> = {},
app?: IApplication,
): RenderResult<Q, Container, BaseElement> {
): App<Q, Container, BaseElement> {
resetDocument();
const user = userEvent.setup();
if (!app) {
return render(ui, options);
return {
...render(ui, options),
user,
} as App<Q, Container, BaseElement>;
}
return render(ui, { wrapper: ApplicationWrapper(app.serviceProvider), ...options });
return {
...render(ui, { wrapper: ApplicationWrapper(app.serviceProvider), ...options }),
user,
} as App<Q, Container, BaseElement>;
}
+2
View File
@@ -1490,6 +1490,7 @@ __metadata:
"@dbeaver/ui-kit": "workspace:*"
"@testing-library/jest-dom": "npm:^6"
"@testing-library/react": "npm:^16"
"@testing-library/user-event": "npm:^14"
"@types/react": "npm:^19"
go-split: "npm:^3"
minisearch: "npm:^7"
@@ -4599,6 +4600,7 @@ __metadata:
"@testing-library/dom": "npm:^10"
"@testing-library/jest-dom": "npm:^6"
"@testing-library/react": "npm:^16"
"@testing-library/user-event": "npm:^14"
"@types/node": "npm:^22"
"@types/react": "npm:^19"
mobx: "npm:^6"