From 1f846c3cb2ea8ea7f698cc87dc17dca8d0fa5342 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 12 Aug 2026 15:35:20 +0200 Subject: [PATCH] fix(frontend): clean test validation output --- frontend/src/api/queries/useAllSettings.ts | 4 ++-- frontend/src/schemas/setting.ts | 9 +++++++++ .../src/test/clients-query-gating.test.tsx | 6 +++++- frontend/src/test/httpUtil.test.ts | 3 +++ frontend/src/test/setup.components.ts | 19 ++++++++++++++++--- frontend/src/test/use-all-settings.test.tsx | 4 +++- frontend/src/test/zodValidate.test.ts | 15 +++++++++++++++ frontend/src/utils/index.ts | 12 ++++++++---- frontend/vitest.config.ts | 5 +++-- 9 files changed, 64 insertions(+), 13 deletions(-) diff --git a/frontend/src/api/queries/useAllSettings.ts b/frontend/src/api/queries/useAllSettings.ts index 8664f2846..04feda08a 100644 --- a/frontend/src/api/queries/useAllSettings.ts +++ b/frontend/src/api/queries/useAllSettings.ts @@ -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 { 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; } diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index 56131721e..a3b760d44 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({ export type AllSettingInput = z.infer; +// 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; diff --git a/frontend/src/test/clients-query-gating.test.tsx b/frontend/src/test/clients-query-gating.test.tsx index 38c79bdfd..98392f624 100644 --- a/frontend/src/test/clients-query-gating.test.tsx +++ b/frontend/src/test/clients-query-gating.test.tsx @@ -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() }); diff --git a/frontend/src/test/httpUtil.test.ts b/frontend/src/test/httpUtil.test.ts index 87ceace7e..61943a9bb 100644 --- a/frontend/src/test/httpUtil.test.ts +++ b/frontend/src/test/httpUtil.test.ts @@ -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 () => { diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 37320380a..30bd7b186 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -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') ? [] : {}, +)); diff --git a/frontend/src/test/use-all-settings.test.tsx b/frontend/src/test/use-all-settings.test.tsx index bf64a7a40..1f9cc8138 100644 --- a/frontend/src/test/use-all-settings.test.tsx +++ b/frontend/src/test/use-all-settings.test.tsx @@ -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 }) => ( {children} @@ -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 () => { diff --git a/frontend/src/test/zodValidate.test.ts b/frontend/src/test/zodValidate.test.ts index 44bad26dc..49e9f392b 100644 --- a/frontend/src/test/zodValidate.test.ts +++ b/frontend/src/test/zodValidate.test.ts @@ -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'] })]), + ); }); }); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index d7c766ce9..a7fd8511c 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -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(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(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; } } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c26a5e7ed..2e1da948f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -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,