diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2920eee3..0f708b7f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,9 +27,6 @@ updates: - 'react-dom' - '@types/react' - '@types/react-dom' - testing: - patterns: - - '@testing-library/*' vite: patterns: - 'vite' @@ -37,6 +34,7 @@ updates: vitest: patterns: - 'vitest' + - 'vitest-browser-react' - '@vitest/*' workbox: patterns: diff --git a/package-lock.json b/package-lock.json index 71c06cb9..65992a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,8 @@ "typescript": "^7.0.2", "vite": "^8.2.0", "vite-plugin-pwa": "^1.3.0", - "vitest": "^4.0.3" + "vitest": "^4.0.3", + "vitest-browser-react": "^2.2.0" } }, "node_modules/@babel/code-frame": { @@ -8801,6 +8802,31 @@ } } }, + "node_modules/vitest-browser-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz", + "integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "vitest": "^4.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -14519,6 +14545,13 @@ "why-is-node-running": "^2.3.0" } }, + "vitest-browser-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz", + "integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==", + "dev": true, + "requires": {} + }, "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index d2a32829..72ea626d 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "typescript": "^7.0.2", "vite": "^8.2.0", "vite-plugin-pwa": "^1.3.0", - "vitest": "^4.0.3" + "vitest": "^4.0.3", + "vitest-browser-react": "^2.2.0" }, "browserslist": [ ">0.2%", diff --git a/test/__helpers__/setUpTest.tsx b/test/__helpers__/setUpTest.tsx index d04e7936..a292d7c7 100644 --- a/test/__helpers__/setUpTest.tsx +++ b/test/__helpers__/setUpTest.tsx @@ -1,17 +1,20 @@ import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import type { RenderOptions } from '@testing-library/react'; -import { render } from '@testing-library/react'; +import { render as testingLibRender } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { PropsWithChildren, ReactElement } from 'react'; import { Provider } from 'react-redux'; +import { render as vitestRender } from 'vitest-browser-react'; import { userEvent } from 'vitest/browser'; import { ContainerProvider } from '../../src/container/context'; import type { RootState } from '../../src/store'; import { setUpStore } from '../../src/store'; +export const render = vitestRender; + export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({ user: userEvent.setup(), - ...render(element, options), + ...testingLibRender(element, options), }); export type RenderOptionsWithState = Omit & { diff --git a/test/common/NotFound.test.tsx b/test/common/NotFound.test.tsx index 2a1e6a19..bb79d34f 100644 --- a/test/common/NotFound.test.tsx +++ b/test/common/NotFound.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { NotFound } from '../../src/common/NotFound'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { render } from '../__helpers__/setUpTest'; describe('', () => { const setUp = (props = {}) => @@ -14,15 +14,15 @@ describe('', () => { it('passes a11y checks', () => checkAccessibility(setUp())); it('shows expected error title', async () => { - setUp(); - await expect.element(screen.getByText('Oops! We could not find requested route.')).toBeInTheDocument(); + const page = await setUp(); + await expect.element(page.getByText('Oops! We could not find requested route.')).toBeInTheDocument(); }); it('shows expected error message', async () => { - setUp(); + const page = await setUp(); await expect .element( - screen.getByText( + page.getByText( "Use your browser's back button to navigate to the page you have previously come from, or just press this button.", ), ) @@ -34,8 +34,8 @@ describe('', () => { [{ to: '/foo/bar', children: 'Hello' }, '/foo/bar', 'Hello'], [{ to: '/baz-bar', children: <>Foo }, '/baz-bar', 'Foo'], ])('shows expected link and text', async (props, expectedLink, expectedText) => { - setUp(props); - const link = screen.getByRole('link'); + const page = await setUp(props); + const link = page.getByRole('link'); await expect.element(link).toHaveAttribute('href', expectedLink); await expect.element(link).toHaveTextContent(expectedText); diff --git a/test/common/ScrollToTop.test.tsx b/test/common/ScrollToTop.test.tsx index 4b2eae1f..ec49f5dd 100644 --- a/test/common/ScrollToTop.test.tsx +++ b/test/common/ScrollToTop.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { ScrollToTop } from '../../src/common/ScrollToTop'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { render } from '../__helpers__/setUpTest.tsx'; describe('', () => { const setUp = (children = 'Foo') => @@ -14,7 +14,7 @@ describe('', () => { it('passes a11y checks', () => checkAccessibility(setUp())); it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', async (children) => { - setUp(children); + const screen = await setUp(children); await expect.element(screen.getByText(children)).toBeInTheDocument(); }); }); diff --git a/test/common/ShlinkVersions.test.tsx b/test/common/ShlinkVersions.test.tsx index 9d7b5ec0..598a4d85 100644 --- a/test/common/ShlinkVersions.test.tsx +++ b/test/common/ShlinkVersions.test.tsx @@ -1,9 +1,9 @@ -import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { ShlinkVersionsProps } from '../../src/common/ShlinkVersions'; import { ShlinkVersions } from '../../src/common/ShlinkVersions'; import type { NonReachableServer, NotFoundServer, ReachableServer } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { render } from '../__helpers__/setUpTest.tsx'; describe('', () => { const setUp = (props: ShlinkVersionsProps) => render(); @@ -20,8 +20,8 @@ describe('', () => { ])( 'displays expected versions when selected server is reachable', async (clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => { - setUp({ clientVersion, selectedServer }); - const [serverLink, clientLink] = screen.getAllByRole('link'); + const screen = await setUp({ clientVersion, selectedServer }); + const [serverLink, clientLink] = screen.getByRole('link').all(); await expect .element(serverLink) @@ -39,8 +39,8 @@ describe('', () => { ['1.2.3', fromPartial({ serverNotFound: true })], ['1.2.3', fromPartial({ serverNotReachable: true })], ])('displays only client version when selected server is not reachable', async (clientVersion, selectedServer) => { - setUp({ clientVersion, selectedServer }); - const links = screen.getAllByRole('link'); + const screen = await setUp({ clientVersion, selectedServer }); + const links = screen.getByRole('link').all(); expect(links).toHaveLength(1); await expect diff --git a/test/common/img/ShlinkLogo.test.tsx b/test/common/img/ShlinkLogo.test.tsx index ec982bdc..78c36442 100644 --- a/test/common/img/ShlinkLogo.test.tsx +++ b/test/common/img/ShlinkLogo.test.tsx @@ -1,8 +1,8 @@ import { brandColor } from '@shlinkio/shlink-frontend-kit'; -import { render } from '@testing-library/react'; import type { ShlinkLogoProps } from '../../../src/common/img/ShlinkLogo'; import { ShlinkLogo } from '../../../src/common/img/ShlinkLogo'; import { checkAccessibility } from '../../__helpers__/accessibility'; +import { render } from '../../__helpers__/setUpTest.tsx'; describe('', () => { const setUp = (props: ShlinkLogoProps = {}) => render(); @@ -14,7 +14,7 @@ describe('', () => { ['red', 'red'], ['white', 'white'], ])('renders expected color', async (color, expectedColor) => { - const { container } = setUp({ color }); + const { container } = await setUp({ color }); await expect.element(container.querySelector('g')).toHaveAttribute('fill', expectedColor); }); @@ -23,7 +23,7 @@ describe('', () => { ['foo', 'foo'], ['bar', 'bar'], ])('renders expected class', async (className, expectedClassName) => { - const { container } = setUp({ className }); + const { container } = await setUp({ className }); if (expectedClassName) { await expect.element(container.firstChild as HTMLElement).toHaveAttribute('class', expectedClassName); diff --git a/test/container/context.test.tsx b/test/container/context.test.tsx index 655124ec..51c74964 100644 --- a/test/container/context.test.tsx +++ b/test/container/context.test.tsx @@ -1,6 +1,6 @@ -import { render } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { ContainerProvider, useDependencies } from '../../src/container/context'; +import { render } from '../__helpers__/setUpTest.tsx'; describe('context', () => { describe('useDependencies', () => { @@ -12,24 +12,24 @@ describe('context', () => { return null; } - it('throws when used outside of ContainerProvider', () => { - expect(() => render()).toThrow( + it('throws when used outside of ContainerProvider', async () => { + await expect(() => render()).rejects.toThrow( 'You cannot use "useDependencies" outside of a ContainerProvider', ); }); - it('throws when requested dependency is not found in container', () => { - expect(() => + it('throws when requested dependency is not found in container', async () => { + await expect(() => render( , ), - ).toThrow('Dependency with name "foo" not found in container'); + ).rejects.toThrow('Dependency with name "foo" not found in container'); }); - it('gets dependency from container', () => { - render( + it('gets dependency from container', async () => { + await render( , diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx index ded7a93a..754643cd 100644 --- a/test/servers/CreateServer.test.tsx +++ b/test/servers/CreateServer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router'; @@ -97,7 +97,7 @@ describe('', () => { fireEvent.submit(page.getByTestId('server-form').element()); - await waitFor(() => expect(page.getByRole('dialog')).toBeInTheDocument()); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); await user.click(page.getByRole('button', { name: 'Discard' })); expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries diff --git a/test/servers/EditServer.test.tsx b/test/servers/EditServer.test.tsx index fbe3d0ec..ea32f47e 100644 --- a/test/servers/EditServer.test.tsx +++ b/test/servers/EditServer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router'; @@ -86,12 +86,10 @@ describe('', () => { await user.click(page.getByLabelText('Forward credentials to this server on every request.')); fireEvent.submit(page.getByTestId('server-form').element()); - await waitFor(() => - expect(store.getState().servers[defaultSelectedServer.id]).toEqual( - expect.objectContaining({ - forwardCredentials: !forwardCredentials, - }), - ), + expect(store.getState().servers[defaultSelectedServer.id]).toEqual( + expect.objectContaining({ + forwardCredentials: !forwardCredentials, + }), ); }, ); diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx index f87c341f..063f7b02 100644 --- a/test/servers/ManageServers.test.tsx +++ b/test/servers/ManageServers.test.tsx @@ -1,4 +1,3 @@ -import { waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; import { page } from 'vitest/browser'; @@ -44,25 +43,25 @@ describe('', () => { await user.type(page.getByPlaceholder('Search...'), searchTerm); }; // Add one for the header row - const expectRows = (amount: number) => expect(page.getByRole('row').elements()).toHaveLength(amount + 1); + const expectRows = (amount: number) => expect.poll(() => page.getByRole('row').elements()).toHaveLength(amount + 1); - expectRows(3); + await expectRows(3); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await search('foo'); - await waitFor(() => expectRows(1)); + await expectRows(1); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await search('Ba'); - await waitFor(() => expectRows(2)); + await expectRows(2); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await search('invalid'); - await waitFor(() => expectRows(1)); + await expectRows(1); await expect.element(page.getByText('No servers found.')).toBeInTheDocument(); }); - it.only.each([[createServerMock('foo')], [createServerMock('foo', true)]])( + it.each([[createServerMock('foo')], [createServerMock('foo', true)]])( 'shows different amount of columns if there are at least one auto-connect server', async (server) => { setUp({ server }); diff --git a/test/servers/ServersListGroup.test.tsx b/test/servers/ServersListGroup.test.tsx index 176497f9..5e993877 100644 --- a/test/servers/ServersListGroup.test.tsx +++ b/test/servers/ServersListGroup.test.tsx @@ -1,9 +1,9 @@ -import { render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; import type { ServerWithId } from '../../src/servers/data'; import { ServersListGroup } from '../../src/servers/ServersListGroup'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { render } from '../__helpers__/setUpTest.tsx'; describe('', () => { const servers: ServerWithId[] = [fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'bar', id: '456' })]; @@ -19,15 +19,15 @@ describe('', () => { it('passes a11y checks', () => checkAccessibility(setUp())); - it.each([[servers], [[]]])('shows servers list', (servers) => { - setUp({ servers }); + it.each([[servers], [[]]])('shows servers list', async (servers) => { + const screen = await setUp({ servers }); - expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0); - expect(screen.queryAllByRole('link')).toHaveLength(servers.length); + expect(screen.getByTestId('list').elements()).toHaveLength(servers.length ? 1 : 0); + expect(screen.getByRole('link').elements()).toHaveLength(servers.length); }); it.each([[true], [false], [undefined]])('renders proper classes for embedded', async (borderless) => { - setUp({ servers, borderless }); + const screen = await setUp({ servers, borderless }); const list = screen.getByTestId('list'); if (!borderless) {