diff --git a/webapp/common-react/@dbeaver/react-tests/src/vitest.setup.ts b/webapp/common-react/@dbeaver/react-tests/src/vitest.setup.ts
index 9e70059713..6eb64e3ac6 100644
--- a/webapp/common-react/@dbeaver/react-tests/src/vitest.setup.ts
+++ b/webapp/common-react/@dbeaver/react-tests/src/vitest.setup.ts
@@ -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;
diff --git a/webapp/packages/core-blocks/package.json b/webapp/packages/core-blocks/package.json
index 91c39d04e0..08e1fe0f47 100644
--- a/webapp/packages/core-blocks/package.json
+++ b/webapp/packages/core-blocks/package.json
@@ -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",
diff --git a/webapp/packages/core-blocks/src/Cell.test.tsx b/webapp/packages/core-blocks/src/Cell.test.tsx
index c626ddb734..4582060461 100644
--- a/webapp/packages/core-blocks/src/Cell.test.tsx
+++ b/webapp/packages/core-blocks/src/Cell.test.tsx
@@ -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) =>
{props.children}
,
}));
diff --git a/webapp/packages/core-blocks/src/ErrorMessage.test.tsx b/webapp/packages/core-blocks/src/ErrorMessage.test.tsx
index 323dc13792..dbf0c8da09 100644
--- a/webapp/packages/core-blocks/src/ErrorMessage.test.tsx
+++ b/webapp/packages/core-blocks/src/ErrorMessage.test.tsx
@@ -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();
await vi.waitFor(() => expect(getByText('error')).toBeInTheDocument());
});
+
+ it('should have role="status"', async () => {
+ const { getByRole } = renderInApp();
+ await vi.waitFor(() => expect(getByRole('status')).toBeInTheDocument());
+ });
});
diff --git a/webapp/packages/core-blocks/src/ErrorMessage.tsx b/webapp/packages/core-blocks/src/ErrorMessage.tsx
index ed0409e8bc..3357a69e08 100644
--- a/webapp/packages/core-blocks/src/ErrorMessage.tsx
+++ b/webapp/packages/core-blocks/src/ErrorMessage.tsx
@@ -18,7 +18,7 @@ interface Props {
hasDetails?: boolean;
text: string;
className?: string;
- onShowDetails?: () => void;
+ onShowDetails?: () => Promise | void;
}
export const ErrorMessage = observer(function ErrorMessage({ text, className, hasDetails, onShowDetails }) {
diff --git a/webapp/packages/core-blocks/src/Icon.test.tsx b/webapp/packages/core-blocks/src/Icon.test.tsx
index b314713b9c..9789cb38a2 100644
--- a/webapp/packages/core-blocks/src/Icon.test.tsx
+++ b/webapp/packages/core-blocks/src/Icon.test.tsx
@@ -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();
- expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/icons/icons.svg#test');
- });
-
- test('/image.jpg', () => {
- (globalThis as any)._ROOT_URI_ = undefined;
-
- const { getByTestId } = renderInApp();
- 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();
- 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();
+ expect(getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/path/image.jpg');
});
});
diff --git a/webapp/packages/core-blocks/src/IconOrImage.test.tsx b/webapp/packages/core-blocks/src/IconOrImage.test.tsx
new file mode 100644
index 0000000000..f8cd4f07d8
--- /dev/null
+++ b/webapp/packages/core-blocks/src/IconOrImage.test.tsx
@@ -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 }) => (
+
+ )),
+}));
+
+vi.mock('./StaticImage.js', () => ({
+ StaticImage: vi.fn(({ ...props }) =>
),
+}));
+
+describe('IconOrImage', () => {
+ test('should render StaticImage for platform: prefixed icon', () => {
+ const { getByRole } = renderInApp();
+ expect(getByRole('img')).toHaveAttribute('icon', 'platform:/image.png');
+ });
+
+ test('should render StaticImage for / prefixed icon', () => {
+ const { getByRole } = renderInApp();
+ expect(getByRole('img')).toHaveAttribute('icon', '/image.jpg');
+ });
+
+ test('should render StaticImage for valid URL', () => {
+ const { getByRole } = renderInApp();
+ expect(getByRole('img')).toHaveAttribute('icon', 'https://example.com/image.png');
+ });
+
+ test('should render Icon for regular icon name', () => {
+ const { getByRole } = renderInApp();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ const element = getByRole('svg');
+ expect(element).toHaveAttribute('viewBox', '0 0 32 32');
+ });
+});
diff --git a/webapp/packages/core-blocks/src/Link.test.tsx b/webapp/packages/core-blocks/src/Link.test.tsx
index ccfe12f249..47863dc2e4 100644
--- a/webapp/packages/core-blocks/src/Link.test.tsx
+++ b/webapp/packages/core-blocks/src/Link.test.tsx
@@ -15,21 +15,14 @@ vi.mock('./IconOrImage', () => ({
IconOrImage: (props: any) => ,
}));
-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(Test Link);
+ const { getByText, getByRole } = renderInApp(Test 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', () => {
diff --git a/webapp/packages/core-blocks/src/StaticImage.test.tsx b/webapp/packages/core-blocks/src/StaticImage.test.tsx
new file mode 100644
index 0000000000..ce46e4265d
--- /dev/null
+++ b/webapp/packages/core-blocks/src/StaticImage.test.tsx
@@ -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();
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('should render img element when icon is provided', () => {
+ const { getByRole } = renderInApp();
+ const img = getByRole('img');
+ expect(img).toBeInTheDocument();
+ });
+
+ test('should use valid URL as-is', () => {
+ const validUrl = 'https://example.com/image.png';
+ const { getByRole } = renderInApp();
+ 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();
+ 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();
+ 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();
+ const img = container.querySelector('img');
+ expect(img).toHaveAttribute('width', width.toString());
+ });
+
+ test('should pass className prop', () => {
+ const className = 'custom-class';
+ const { getByRole } = renderInApp();
+ const img = getByRole('img');
+ expect(img).toHaveClass(className);
+ });
+
+ test('should handle onClick event', () => {
+ const handleClick = vi.fn();
+ const { getByRole } = renderInApp();
+ const img = getByRole('img');
+
+ fireEvent.click(img!);
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('should pass additional img props', () => {
+ const { getByRole } = renderInApp();
+ 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();
+ expect(sMock).toHaveBeenCalledWith({}, { block: true }, undefined);
+ });
+
+ test('should not apply block class when block prop is false', () => {
+ const sMock = vi.spyOn(sModule, 's');
+ renderInApp();
+ expect(sMock).toHaveBeenCalledWith({}, { block: false }, undefined);
+ });
+});
diff --git a/webapp/packages/core-blocks/src/StatusMessage.test.tsx b/webapp/packages/core-blocks/src/StatusMessage.test.tsx
index 3d8c730cc3..e098d2fcac 100644
--- a/webapp/packages/core-blocks/src/StatusMessage.test.tsx
+++ b/webapp/packages/core-blocks/src/StatusMessage.test.tsx
@@ -18,10 +18,6 @@ vi.mock('./localization/useTranslate', () => ({
useTranslate: () => (key: string) => key,
}));
-vi.mock('./s', () => ({
- s: vi.fn(),
-}));
-
vi.mock('./IconOrImage', () => ({
IconOrImage: (props: any) => ,
}));
@@ -30,10 +26,6 @@ vi.mock('./Link', () => ({
Link: (props: any) => ,
}));
-vi.mock('./useS', () => ({
- useS: vi.fn(),
-}));
-
vi.mock('./useErrorDetails', () => ({
useErrorDetails: (exception: Error | null) => ({
message: exception?.message || '',
diff --git a/webapp/packages/core-blocks/src/StatusMessage.tsx b/webapp/packages/core-blocks/src/StatusMessage.tsx
index 4ff92676f4..601ecde694 100644
--- a/webapp/packages/core-blocks/src/StatusMessage.tsx
+++ b/webapp/packages/core-blocks/src/StatusMessage.tsx
@@ -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;
}
export const StatusMessage = observer(function StatusMessage({
diff --git a/webapp/packages/core-blocks/src/Text.test.tsx b/webapp/packages/core-blocks/src/Text.test.tsx
index fe6eb2a3fb..9a64747430 100644
--- a/webapp/packages/core-blocks/src/Text.test.tsx
+++ b/webapp/packages/core-blocks/src/Text.test.tsx
@@ -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(Hello World);
diff --git a/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx b/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx
index 1920d52fa5..5b02bd2839 100644
--- a/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx
+++ b/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx
@@ -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(Hello World);
diff --git a/webapp/packages/core-blocks/src/TimerIcon.test.tsx b/webapp/packages/core-blocks/src/TimerIcon.test.tsx
index d3db92285d..62e95126fe 100644
--- a/webapp/packages/core-blocks/src/TimerIcon.test.tsx
+++ b/webapp/packages/core-blocks/src/TimerIcon.test.tsx
@@ -14,14 +14,6 @@ vi.mock('./Icon', () => ({
Icon: (props: any) => ,
}));
-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();
diff --git a/webapp/packages/core-blocks/src/UploadArea.test.tsx b/webapp/packages/core-blocks/src/UploadArea.test.tsx
new file mode 100644
index 0000000000..a6fa5bf325
--- /dev/null
+++ b/webapp/packages/core-blocks/src/UploadArea.test.tsx
@@ -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(Upload File);
+ const label = await vi.waitFor(() => getByText('Upload File'));
+
+ expect(label).toBeInTheDocument();
+ expect(label.tagName).toBe('LABEL');
+ });
+
+ it('should render input file', () => {
+ const { container } = renderInApp(Upload File);
+ 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(Upload File);
+ 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(Upload File);
+ 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(
+
+ Upload File
+ ,
+ );
+ 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(Upload File);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ expect(input).toBeDisabled();
+ });
+
+ it('should forward ref correctly', () => {
+ const ref = createRef();
+ renderInApp(Upload File);
+
+ 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(Upload File);
+ 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(Upload Files);
+ 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(Upload Image);
+ 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(Upload File);
+ 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(Upload File);
+ 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(Upload File);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+
+ const { file } = createTestFileList();
+ await user.upload(input, file);
+
+ expect(handleChange).toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+});
diff --git a/webapp/packages/core-blocks/src/useAdministrationSettings.test.ts b/webapp/packages/core-blocks/src/useAdministrationSettings.test.ts
new file mode 100644
index 0000000000..b6fd8da95b
--- /dev/null
+++ b/webapp/packages/core-blocks/src/useAdministrationSettings.test.ts
@@ -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;
+ let mockUseResource: ReturnType;
+
+ beforeEach(async () => {
+ const { usePermission } = await import('./usePermission.js');
+ const { useResource } = await import('./ResourcesHooks/useResource.js');
+
+ mockUsePermission = usePermission as ReturnType;
+ mockUseResource = useResource as ReturnType;
+ });
+
+ 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);
+ });
+});
diff --git a/webapp/packages/core-blocks/src/useCombinedHandler.test.ts b/webapp/packages/core-blocks/src/useCombinedHandler.test.ts
index eb843e264b..c7a701ed55 100644
--- a/webapp/packages/core-blocks/src/useCombinedHandler.test.ts
+++ b/webapp/packages/core-blocks/src/useCombinedHandler.test.ts
@@ -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();
diff --git a/webapp/packages/core-blocks/src/useErrorDetails.test.ts b/webapp/packages/core-blocks/src/useErrorDetails.test.ts
new file mode 100644
index 0000000000..deb6c5fa43
--- /dev/null
+++ b/webapp/packages/core-blocks/src/useErrorDetails.test.ts
@@ -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;
+
+ beforeEach(() => {
+ vitest.clearAllMocks();
+ mockErrorDetailsDialog = ErrorDetailsDialog as unknown as ReturnType;
+ });
+
+ 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(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);
+ });
+});
diff --git a/webapp/packages/core-blocks/src/useErrorDetails.ts b/webapp/packages/core-blocks/src/useErrorDetails.ts
index 2be88df24e..7517565637 100644
--- a/webapp/packages/core-blocks/src/useErrorDetails.ts
+++ b/webapp/packages/core-blocks/src/useErrorDetails.ts
@@ -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;
refresh?: () => void;
}
diff --git a/webapp/packages/core-blocks/src/useExecutor.test.ts b/webapp/packages/core-blocks/src/useExecutor.test.ts
index d54ee2763c..757e39cfec 100644
--- a/webapp/packages/core-blocks/src/useExecutor.test.ts
+++ b/webapp/packages/core-blocks/src/useExecutor.test.ts
@@ -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;
@@ -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);
- });
});
diff --git a/webapp/packages/core-blocks/src/useFuzzySearch.test.ts b/webapp/packages/core-blocks/src/useFuzzySearch.test.ts
new file mode 100644
index 0000000000..08985f3822
--- /dev/null
+++ b/webapp/packages/core-blocks/src/useFuzzySearch.test.ts
@@ -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');
+ });
+});
diff --git a/webapp/packages/core-blocks/src/useObjectRef.test.ts b/webapp/packages/core-blocks/src/useObjectRef.test.ts
index 06de428d5e..9b9eee3859 100644
--- a/webapp/packages/core-blocks/src/useObjectRef.test.ts
+++ b/webapp/packages/core-blocks/src/useObjectRef.test.ts
@@ -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>(() => ({ 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');
});
});
diff --git a/webapp/packages/core-blocks/src/useObservableRef.test.ts b/webapp/packages/core-blocks/src/useObservableRef.test.ts
index 16dac2ad31..a8616765c4 100644
--- a/webapp/packages/core-blocks/src/useObservableRef.test.ts
+++ b/webapp/packages/core-blocks/src/useObservableRef.test.ts
@@ -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));
diff --git a/webapp/packages/core-blocks/src/useSearch.test.ts b/webapp/packages/core-blocks/src/useSearch.test.ts
new file mode 100644
index 0000000000..5aa1c561ca
--- /dev/null
+++ b/webapp/packages/core-blocks/src/useSearch.test.ts
@@ -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');
+ });
+ });
+});
diff --git a/webapp/packages/core-di/src/__tests__/app-init/app-init.test.ts b/webapp/packages/core-di/src/__tests__/app-init/app-init.test.ts
index e84ccd34c1..9475367fb2 100644
--- a/webapp/packages/core-di/src/__tests__/app-init/app-init.test.ts
+++ b/webapp/packages/core-di/src/__tests__/app-init/app-init.test.ts
@@ -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();
diff --git a/webapp/packages/core-utils/src/copyToClipboard.test.ts b/webapp/packages/core-utils/src/copyToClipboard.test.ts
index 3684273e38..195a988c3c 100644
--- a/webapp/packages/core-utils/src/copyToClipboard.test.ts
+++ b/webapp/packages/core-utils/src/copyToClipboard.test.ts
@@ -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', () => {
diff --git a/webapp/packages/core-utils/src/downloadImage.ts b/webapp/packages/core-utils/src/downloadImage.ts
index 2b220cbd79..a8d007b7dd 100644
--- a/webapp/packages/core-utils/src/downloadImage.ts
+++ b/webapp/packages/core-utils/src/downloadImage.ts
@@ -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(element: T, options: Options, fileName: string): Promise {
const svg = await modernScreenshot.domToForeignObjectSvg(element, options);
diff --git a/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts b/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts
index cc7d023aa8..3433e71bb5 100644
--- a/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts
+++ b/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts
@@ -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;
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');
diff --git a/webapp/packages/core-utils/src/isBrokenImage.test.ts b/webapp/packages/core-utils/src/isBrokenImage.test.ts
new file mode 100644
index 0000000000..68772965ab
--- /dev/null
+++ b/webapp/packages/core-utils/src/isBrokenImage.test.ts
@@ -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)['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');
+ });
+});
diff --git a/webapp/packages/core-utils/src/isIp.test.ts b/webapp/packages/core-utils/src/isIp.test.ts
new file mode 100644
index 0000000000..09a417042b
--- /dev/null
+++ b/webapp/packages/core-utils/src/isIp.test.ts
@@ -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);
+ });
+});
diff --git a/webapp/packages/core-utils/src/isIp.ts b/webapp/packages/core-utils/src/isIp.ts
index 93d2a3bc70..20c34efb8d 100644
--- a/webapp/packages/core-utils/src/isIp.ts
+++ b/webapp/packages/core-utils/src/isIp.ts
@@ -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}]`;
}
diff --git a/webapp/packages/eslint-config/flat.js b/webapp/packages/eslint-config/flat.js
index 51125b0988..c7a357c58e 100644
--- a/webapp/packages/eslint-config/flat.js
+++ b/webapp/packages/eslint-config/flat.js
@@ -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',
diff --git a/webapp/packages/tests-runner/package.json b/webapp/packages/tests-runner/package.json
index 355000fca4..0400ed1737 100644
--- a/webapp/packages/tests-runner/package.json
+++ b/webapp/packages/tests-runner/package.json
@@ -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",
diff --git a/webapp/packages/tests-runner/src/renderInApp.tsx b/webapp/packages/tests-runner/src/renderInApp.tsx
index 1d3092e266..de5ed23901 100644
--- a/webapp/packages/tests-runner/src/renderInApp.tsx
+++ b/webapp/packages/tests-runner/src/renderInApp.tsx
@@ -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 = RenderResult & { user: ReturnType };
+
// 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, 'queries' | 'wrapper'> = {},
app?: IApplication,
-): RenderResult {
+): App {
resetDocument();
+ const user = userEvent.setup();
+
if (!app) {
- return render(ui, options);
+ return {
+ ...render(ui, options),
+ user,
+ } as App;
}
- return render(ui, { wrapper: ApplicationWrapper(app.serviceProvider), ...options });
+ return {
+ ...render(ui, { wrapper: ApplicationWrapper(app.serviceProvider), ...options }),
+ user,
+ } as App;
}
diff --git a/webapp/yarn.lock b/webapp/yarn.lock
index 623d02d919..0bc4d7b48e 100644
--- a/webapp/yarn.lock
+++ b/webapp/yarn.lock
@@ -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"