Run tests in a headless browser with vitest browser mode and playwright

This commit is contained in:
Alejandro Celaya 2025-04-08 11:49:33 +02:00
parent 09559c78af
commit 691e6c1afb
15 changed files with 451 additions and 182 deletions

View File

@ -13,3 +13,4 @@ jobs:
with:
node-version: 22.x
publish-coverage: true
install-playwright: true

View File

@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* Update to `@shlinkio/shlink-web-component` 0.13.0
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
* Add `eslint-plugin-react-compiler`
* Run unit tests in a headless browser using vitest browser mode and playwright.
### Deprecated
* *Nothing*

View File

@ -1,33 +1,9 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import axe from 'axe-core';
import { afterEach } from 'vitest';
axe.configure({
checks: [
{
// Disable color contrast checking, as it doesn't work in jsdom
id: 'color-contrast',
enabled: false,
},
],
});
// Clear all mocks and cleanup DOM after every test
afterEach(() => {
vi.clearAllMocks();
cleanup();
});
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
(global as any).scrollTo = () => {};
(global as any).matchMedia = () => ({ matches: false });
HTMLDialogElement.prototype.showModal = function() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close = function() {
this.removeAttribute('open');
this.dispatchEvent(new CloseEvent('close'));
this.dispatchEvent(new CloseEvent('cancel'));
};

8
dev.Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM mcr.microsoft.com/playwright:v1.51.1-noble
ENV NODE_VERSION 22.14
# Install Node.js
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
\. "$HOME/.nvm/nvm.sh" && \
nvm install ${NODE_VERSION}

View File

@ -2,7 +2,9 @@ services:
shlink_web_client_node:
container_name: shlink_web_client_node
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
image: node:22.12-alpine
build:
context: .
dockerfile: ./dev.Dockerfile
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www

476
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^3.1.1",
"@vitest/coverage-v8": "^3.1.1",
"adm-zip": "^0.5.16",
"axe-core": "^4.10.3",
@ -74,7 +75,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0",
"jsdom": "^26.0.0",
"playwright": "^1.51.1",
"sass": "^1.86.3",
"tailwindcss": "^4.0.17",
"typescript": "^5.8.3",

View File

@ -42,7 +42,7 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
</DropdownToggle>
<DropdownMenu end classNam="tw:right-0">{renderServers()}</DropdownMenu>
<DropdownMenu end className="tw:right-0">{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};

View File

@ -96,6 +96,7 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
accept=".csv"
className="tw:hidden"
aria-hidden
tabIndex={-1}
ref={ref as any /* TODO Remove After updating to React 19 */}
onChange={onFile}
data-testid="csv-file-input"

View File

@ -1,46 +0,0 @@
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
import type { VisitsSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
export type DateInterval = VisitsSettings['defaultInterval'];
export interface DateIntervalSelectorProps {
active?: DateInterval;
allText: string;
onChange: (interval: DateInterval) => void;
}
export const INTERVAL_TO_STRING_MAP: Record<Exclude<DateInterval, 'all'>, string> = {
today: 'Today',
yesterday: 'Yesterday',
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
last90Days: 'Last 90 days',
last180Days: 'Last 180 days',
last365Days: 'Last 365 days',
};
const intervalToString = (interval: DateInterval | undefined, fallback: string): string => {
if (!interval || interval === 'all') {
return fallback;
}
return INTERVAL_TO_STRING_MAP[interval];
};
export const DateIntervalSelector: FC<DateIntervalSelectorProps> = ({ onChange, active, allText }) => (
<DropdownBtn text={intervalToString(active, allText)}>
<DropdownItem active={active === 'all'} onClick={() => onChange('all')}>
{allText}
</DropdownItem>
<DropdownItem divider />
{Object.entries(INTERVAL_TO_STRING_MAP).map(
([interval, name]) => (
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval as DateInterval)}>
{name}
</DropdownItem>
),
)}
</DropdownBtn>
);

View File

@ -24,15 +24,13 @@ describe('<ManageServersRowDropdown />', () => {
};
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
it.each([
[setUp],
[async () => {
const { user, container } = setUp();
await toggleDropdown(user);
it('passes a11y checks', async () => {
const { user, container } = setUp();
// Open menu
await toggleDropdown(user);
return { container };
}],
])('passes a11y checks', (setUp) => checkAccessibility(setUp()));
return checkAccessibility({ container });
});
it('renders expected amount of dropdown items', async () => {
const { user } = setUp();

View File

@ -20,7 +20,13 @@ describe('<ServersDropdown />', () => {
</MemoryRouter>,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('passes a11y checks', async () => {
const { user, ...rest } = setUp();
// Open menu
await user.click(screen.getByText('Servers'));
return checkAccessibility(rest);
});
it('contains the list of servers and the "mange servers" button', async () => {
const { user } = setUp();

View File

@ -24,17 +24,13 @@ describe('ServersExporter', () => {
const createCsvjsonMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
describe('exportServers', () => {
let originalConsole: Console;
const error = vi.fn();
beforeEach(() => {
originalConsole = global.console;
global.console = fromPartial<Console>({ error });
(global as any).Blob = class Blob {};
(global as any).URL = { createObjectURL: () => '' };
vi.stubGlobal('console', fromPartial<Console>({ error }));
});
afterEach(() => {
global.console = originalConsole;
vi.unstubAllGlobals();
});
it('logs an error if something fails', () => {

View File

@ -1,28 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import type { DateInterval } from '../../../src/utils/dates/DateIntervalSelector';
import { DateIntervalSelector, INTERVAL_TO_STRING_MAP } from '../../../src/utils/dates/DateIntervalSelector';
import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateIntervalSelector />', () => {
const activeInterval: DateInterval = 'last7Days';
const onChange = vi.fn();
const setUp = () => renderWithEvents(
<DateIntervalSelector allText="All text" active={activeInterval} onChange={onChange} />,
);
it('passes a11y checks', () => checkAccessibility(setUp()));
it('passes props down to nested DateIntervalDropdownItems', async () => {
const { user } = setUp();
const btn = screen.getByRole('button');
await user.click(btn);
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
const items = screen.getAllByRole('menuitem');
expect(btn).toHaveTextContent(INTERVAL_TO_STRING_MAP[activeInterval] ?? '');
expect(items).toHaveLength(8);
});
});

View File

@ -35,9 +35,16 @@ export default defineConfig({
// Vitest config
test: {
// Run tests in an actual browser
browser: {
provider: 'playwright',
enabled: true,
headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }],
},
globals: true,
allowOnly: true,
environment: 'jsdom',
setupFiles: './config/test/setupTests.ts',
coverage: {
provider: 'v8',