mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 06:40:59 +00:00
fix(frontend): clean test validation output
This commit is contained in:
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { AllSetting } from '@/models/setting';
|
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 { keys } from '@/api/queryKeys';
|
||||||
import { useServerDraft } from '@/hooks/useServerDraft';
|
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ type SettingSaveResult = {
|
|||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
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;
|
return validated.obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({
|
|||||||
|
|
||||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
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 const FactoryDefaultsSchema = z.record(z.string(), z.string());
|
||||||
|
|
||||||
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
|
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 () => {
|
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));
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
|
||||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, stat
|
|||||||
describe('HttpUtil', () => {
|
describe('HttpUtil', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unwraps a success envelope and shows a success toast', async () => {
|
it('unwraps a success envelope and shows a success toast', async () => {
|
||||||
@@ -80,6 +81,7 @@ describe('HttpUtil', () => {
|
|||||||
|
|
||||||
expect(msg.success).toBe(false);
|
expect(msg.success).toBe(false);
|
||||||
expect(msg.msg).toBe('bad input');
|
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 () => {
|
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 });
|
const msg = await HttpUtil.get('/x', undefined, { silent: true });
|
||||||
|
|
||||||
expect(msg.msg).toBe('Network down');
|
expect(msg.msg).toBe('Network down');
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns "No response data" for an empty body', async () => {
|
it('returns "No response data" for an empty body', async () => {
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ if (!Element.prototype.scrollIntoView) {
|
|||||||
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) {
|
if (!i18next.isInitialized) {
|
||||||
void i18next.use(initReactI18next).init({
|
void i18next.use(initReactI18next).init({
|
||||||
lng: 'en-US',
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
|
||||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
|
true,
|
||||||
|
'',
|
||||||
|
url.includes('/panel/api/inbounds/options') ? [] : {},
|
||||||
|
));
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('useAllSettings', () => {
|
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);
|
const subJsonUserAgentRegex = 'x'.repeat(2_049);
|
||||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
|
||||||
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
const queryClient = makeTestQueryClient();
|
const queryClient = makeTestQueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
@@ -25,6 +26,7 @@ describe('useAllSettings', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
||||||
|
expect(warning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an edited setting when a refetch returns older server data', async () => {
|
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ afterEach(() => {
|
|||||||
describe('parseMsg', () => {
|
describe('parseMsg', () => {
|
||||||
it('rejects a successful response whose payload violates its schema', () => {
|
it('rejects a successful response whose payload violates its schema', () => {
|
||||||
const msg = new Msg(true, '', { id: 'not-a-number' });
|
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(
|
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
|
||||||
'test/value response failed validation',
|
'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', () => {
|
it('preserves a missing successful payload for callers that handle empty values', () => {
|
||||||
@@ -25,17 +30,27 @@ describe('parseMsg', () => {
|
|||||||
|
|
||||||
it('rejects malformed paged-client payloads', () => {
|
it('rejects malformed paged-client payloads', () => {
|
||||||
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
|
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(
|
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
|
||||||
'clients/list/paged response failed validation',
|
'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', () => {
|
describe('fetchXrayConfig', () => {
|
||||||
it('keeps a malformed xray payload available for repair', async () => {
|
it('keeps a malformed xray payload available for repair', async () => {
|
||||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
|
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' });
|
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'] })]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ export class HttpUtil {
|
|||||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET request failed:', error);
|
|
||||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||||
const data = err.response?.data;
|
const data = err.response?.data;
|
||||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
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;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,11 +94,13 @@ export class HttpUtil {
|
|||||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('POST request failed:', error);
|
|
||||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||||
const data = err.response?.data;
|
const data = err.response?.data;
|
||||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
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;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -17,6 +16,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: false,
|
globals: false,
|
||||||
|
// Keep jsdom-heavy form tests within the memory budget of local and CI runners.
|
||||||
|
maxWorkers: 2,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
extends: true,
|
extends: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user