fix(frontend): clean test validation output

This commit is contained in:
Sanaei
2026-08-12 15:35:20 +02:00
parent 1c255fc00c
commit 1f846c3cb2
9 changed files with 64 additions and 13 deletions
+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,