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) => {props.children}, })); -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) => Icon, })); -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"