Преглед изворни кода

fix(frontend): clean test validation output

Sanaei пре 4 часа
родитељ
комит
1f846c3cb2

+ 2 - 2
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<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 - 0
frontend/src/schemas/setting.ts

@@ -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>;

+ 5 - 1
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() });
 

+ 3 - 0
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 () => {

+ 16 - 3
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') ? [] : {},
+));

+ 3 - 1
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 }) => (
       <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 - 0
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'] })]),
+    );
   });
 });

+ 8 - 4
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<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
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,