Compare commits

..

5 Commits

Author SHA1 Message Date
Sanaei 1f846c3cb2 fix(frontend): clean test validation output 2026-08-12 15:35:20 +02:00
Sanaei 1c255fc00c chore(frontend): bump npm dependencies
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements.
2026-08-12 14:06:18 +02:00
dependabot[bot] 75032fd498 chore(deps): bump dompurify (#6193)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [dompurify](https://github.com/cure53/DOMPurify).


Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 13:40:13 +02:00
Sanaei ece1655939 fix(docs): prevent theme switch hydration mismatch 2026-08-06 20:29:00 +02:00
Sanaei cb902314db fix(docs): restore theme switch without runtime warnings
Move html/body shell and global css to root app layout to avoid hydration/script warnings from nested document nodes. Disable provider theme injection and add a custom script-free theme switch in shared layout slots.

Also migrate docs search static client initializer to ZBSearch (initDB), add zbsearch dependency, and align docs lint tooling with ESLint 9 compatibility so npm run lint passes.
2026-08-06 17:59:23 +02:00
19 changed files with 1463 additions and 1188 deletions
+4 -16
View File
@@ -1,30 +1,18 @@
import '../global.css';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { Inter, Vazirmatn } from 'next/font/google';
import { i18n, localeDirection } from '@/lib/i18n';
import { i18n } from '@/lib/i18n';
import { provider } from '@/lib/i18n-ui';
import SearchDialog from '@/components/search-dialog';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function LangLayout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
const dir = localeDirection(lang);
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
return (
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
<RootProvider i18n={provider(lang)} search={{ SearchDialog }}>
{children}
</RootProvider>
</body>
</html>
<RootProvider i18n={provider(lang)} search={{ SearchDialog }} theme={{ enabled: false }}>
{children}
</RootProvider>
);
}
+30 -5
View File
@@ -1,10 +1,16 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Inter, Vazirmatn } from 'next/font/google';
import './global.css';
import { appName, appTagline, siteUrl } from '@/lib/shared';
import { i18n, localeDirection } from '@/lib/i18n';
// Global SEO defaults. The real <html>/<body> live in `app/[lang]/layout.tsx`
// so we can set `lang`/`dir` per locale (RTL for fa); this root layout is a
// pass-through that only carries site-wide metadata.
const inter = Inter({ subsets: ['latin'], display: 'swap' });
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
// Global SEO defaults and document shell. Locale-aware html attributes are
// computed from route params so RTL locales get a correct base direction.
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
@@ -26,6 +32,25 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
export default async function RootLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang?: string }>;
}) {
const { lang: rawLang } = await params;
const lang = i18n.languages.includes(rawLang as (typeof i18n.languages)[number])
? (rawLang as (typeof i18n.languages)[number])
: i18n.defaultLanguage;
const dir = localeDirection(lang);
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
return (
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
{children}
</body>
</html>
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { create } from '@orama/orama';
import { create } from 'zbsearch';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static';
import {
@@ -25,8 +25,8 @@ interface SharedProps {
// default static dialog feeds those codes to Orama as a tokenizer language, but
// Orama only accepts full names ("english") and throws on "en" — which silently
// breaks search entirely. All docs content is English (other locales fall back
// to it), so re-create the dialog — the documented escape hatch for custom Orama
// setups — with an initOrama that always builds an English index.
// to it), so re-create the dialog — the documented escape hatch for custom search
// setups — with an initDB that always builds an English index.
export default function SearchDialogClient(props: SharedProps) {
const { locale } = useI18n();
const client = useMemo(
@@ -34,7 +34,7 @@ export default function SearchDialogClient(props: SharedProps) {
oramaStaticClient({
from: '/api/search',
locale,
initOrama: () => create({ schema: { _: 'string' }, language: 'english' }),
initDB: () => create({ schema: { _: 'string' }, language: 'english' }),
}),
[locale],
);
+104
View File
@@ -0,0 +1,104 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState, useSyncExternalStore } from 'react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
type ThemeMode = 'light-dark' | 'light-dark-system';
type ThemePref = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'docs-theme';
// `useSyncExternalStore` supplies the same value for SSR and hydration, then
// switches to the browser value after React has attached to the markup.
const subscribeToHydration = () => () => {};
const getHydrationClientSnapshot = () => true;
const getHydrationServerSnapshot = () => false;
function getStoredTheme(): ThemePref {
if (typeof window === 'undefined') return 'system';
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
}
function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: ThemePref): void {
if (typeof document === 'undefined') return;
const resolved = getResolvedTheme(theme);
const root = document.documentElement;
root.classList.toggle('dark', resolved === 'dark');
root.style.colorScheme = resolved;
}
export function DocsThemeSwitch({
className,
mode = 'light-dark-system',
...props
}: {
className?: string;
mode?: ThemeMode;
} & Omit<ComponentProps<'div'>, 'children'>) {
// Keep the server and first client render identical. Reading localStorage or
// matchMedia here would make a persisted/system preference change the client
// markup before React has finished hydrating it.
const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
const hydrated = useSyncExternalStore(
subscribeToHydration,
getHydrationClientSnapshot,
getHydrationServerSnapshot,
);
const theme = hydrated ? getStoredTheme() : selectedTheme;
useEffect(() => {
if (hydrated) applyTheme(theme);
}, [hydrated, theme]);
useEffect(() => {
if (!hydrated) return;
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => applyTheme('system');
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [hydrated, theme]);
const resolved = hydrated ? getResolvedTheme(theme) : 'light';
const setTheme = (nextTheme: ThemePref) => {
window.localStorage.setItem(STORAGE_KEY, nextTheme);
applyTheme(nextTheme);
setSelectedTheme(nextTheme);
};
const nextTheme = () => {
if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
if (theme === 'light') return 'dark';
if (theme === 'dark') return 'system';
return resolved === 'dark' ? 'light' : 'dark';
};
const label =
mode === 'light-dark-system'
? `Switch theme (current: ${theme})`
: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
return (
<div className={cn('inline-flex', className)} {...props}>
<button
type="button"
aria-label={label}
title={label}
onClick={() => setTheme(nextTheme())}
className="inline-flex size-8 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
>
{resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
</button>
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Heart } from 'lucide-react';
import { Logo } from '@/components/logo';
import { TelegramIcon } from '@/components/icons';
import { DocsThemeSwitch } from '@/components/theme-switch';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
import { getSiteMessages } from './site-i18n';
@@ -12,6 +13,9 @@ export function baseOptions(lang: string): BaseLayoutProps {
const m = getSiteMessages(lang);
return {
slots: {
themeSwitch: DocsThemeSwitch,
},
nav: {
title: (
<span className="inline-flex items-center gap-2 font-semibold">
+16 -14
View File
@@ -19,34 +19,36 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
"fumadocs-core": "^16.11.5",
"fumadocs-core": "^16.14.1",
"fumadocs-docgen": "^3.1.0",
"fumadocs-mdx": "^15.2.0",
"fumadocs-openapi": "^11.2.2",
"fumadocs-ui": "^16.11.5",
"lucide-react": "^1.25.0",
"mermaid": "^11.16.0",
"next": "16.2.11",
"fumadocs-mdx": "^15.2.2",
"fumadocs-openapi": "^11.2.3",
"fumadocs-ui": "^16.14.1",
"lucide-react": "^1.29.0",
"mermaid": "^11.16.1",
"next": "16.3.0",
"next-themes": "^0.4.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-qr-code": "^2.2.0",
"tailwind-merge": "^3.6.0",
"zbsearch": "3.3.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^9.39.5",
"eslint-config-next": "16.2.11",
"postcss": "^8.5.23",
"eslint-config-next": "16.3.0",
"eslint-plugin-react": "^7.37.5",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript": "6.0.3",
"vitest": "^4.1.10"
},
"packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
"packageManager": "pnpm@11.20.0"
}
+683 -540
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,3 +10,7 @@ overrides:
minimumReleaseAgeExclude:
- '@mermaid-js/parser@1.2.0'
- mermaid@11.16.0
- fumadocs-core@16.14.1
- fumadocs-ui@16.14.1
- lucide-react@1.29.0
- postcss@8.5.26
+536 -582
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -31,30 +31,30 @@
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.7.1",
"@noble/hashes": "^2.2.0",
"@noble/hashes": "^2.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.3",
"antd": "^6.6.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"persian-calendar-suite": "^1.5.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.84.0",
"react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11",
"react-router": "^8.3.0",
"swagger-ui-react": "^5.32.12",
"swagger-ui-react": "^5.32.13",
"uplot": "^1.6.32",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.6",
"@storybook/addon-docs": "^10.5.6",
"@storybook/addon-vitest": "^10.5.6",
"@storybook/react-vite": "^10.5.6",
"@storybook/addon-a11y": "^10.5.7",
"@storybook/addon-docs": "^10.5.7",
"@storybook/addon-vitest": "^10.5.7",
"@storybook/react-vite": "^10.5.7",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
@@ -63,19 +63,19 @@
"@vitejs/plugin-react": "^6.0.5",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.9.0",
"globals": "^17.11.0",
"husky": "^9.1.7",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"msw": "^2.15.0",
"playwright": "^1.62.1",
"storybook": "^10.5.6",
"storybook": "^10.5.7",
"typescript": "6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "8.2.0",
"typescript-eslint": "^8.67.0",
"vite": "8.2.1",
"vitest": "^4.1.10"
},
"overrides": {
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
@@ -17,7 +17,7 @@ type SettingSaveResult = {
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
const validated = parseMsg(msg, AllSettingResponseSchema, 'setting/all');
return validated.obj;
}
+9
View File
@@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
// Existing installations can contain regex values saved before the backend
// enforced its 2,048-character limit. Accept those values when reading so the
// settings page can display and let users correct them, while keeping the
// stricter schema above for outgoing updates.
export const AllSettingResponseSchema = AllSettingSchema.extend({
subJsonUserAgentRegex: z.string().optional(),
subClashUserAgentRegex: z.string().optional(),
});
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
@@ -99,7 +99,11 @@ describe('useClients query gating', () => {
});
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/inbounds/options') ? [] : emptyPage,
));
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+3
View File
@@ -35,6 +35,7 @@ const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, stat
describe('HttpUtil', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
it('unwraps a success envelope and shows a success toast', async () => {
@@ -80,6 +81,7 @@ describe('HttpUtil', () => {
expect(msg.success).toBe(false);
expect(msg.msg).toBe('bad input');
expect(console.error).not.toHaveBeenCalled();
});
it('maps a thrown native error to a failure Msg via its message', async () => {
@@ -88,6 +90,7 @@ describe('HttpUtil', () => {
const msg = await HttpUtil.get('/x', undefined, { silent: true });
expect(msg.msg).toBe('Network down');
expect(console.error).not.toHaveBeenCalled();
});
it('returns "No response data" for an empty body', async () => {
+16 -3
View File
@@ -48,6 +48,16 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// jsdom does not implement pseudo-element styles or Range geometry. Ant
// Design and CodeMirror use these APIs for layout, so supply harmless test
// fallbacks instead of emitting noisy "Not implemented" errors.
const nativeGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
if (!Range.prototype.getClientRects) {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
}
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: 'en-US',
@@ -75,9 +85,12 @@ afterEach(async () => {
}
});
import { HttpUtil } from '@/utils';
import { HttpUtil, Msg } from '@/utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/panel/api/inbounds/options') ? [] : {},
));
+3 -1
View File
@@ -13,9 +13,10 @@ afterEach(() => {
});
describe('useAllSettings', () => {
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
const subJsonUserAgentRegex = 'x'.repeat(2_049);
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -25,6 +26,7 @@ describe('useAllSettings', () => {
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
expect(warning).not.toHaveBeenCalled();
});
it('keeps an edited setting when a refetch returns older server data', async () => {
+15
View File
@@ -13,10 +13,15 @@ afterEach(() => {
describe('parseMsg', () => {
it('rejects a successful response whose payload violates its schema', () => {
const msg = new Msg(true, '', { id: 'not-a-number' });
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
'test/value response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] test/value response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['id'] })]),
);
});
it('preserves a missing successful payload for callers that handle empty values', () => {
@@ -25,17 +30,27 @@ describe('parseMsg', () => {
it('rejects malformed paged-client payloads', () => {
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
'clients/list/paged response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] clients/list/paged response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['total'] })]),
);
});
});
describe('fetchXrayConfig', () => {
it('keeps a malformed xray payload available for repair', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
expect(warning).toHaveBeenCalledWith(
'[zod] xray/ config payload failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] })]),
);
});
});
+8 -4
View File
@@ -75,11 +75,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('GET request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('GET request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
@@ -92,11 +94,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('POST request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('POST request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
+3 -2
View File
@@ -1,12 +1,11 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const dirname = import.meta.dirname;
export default defineConfig({
plugins: [react()],
@@ -17,6 +16,8 @@ export default defineConfig({
},
test: {
globals: false,
// Keep jsdom-heavy form tests within the memory budget of local and CI runners.
maxWorkers: 2,
projects: [
{
extends: true,