mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 05:57:19 +00:00
Compare commits
4 Commits
ece1655939
...
8a8da88548
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8da88548 | |||
| 1f846c3cb2 | |||
| 1c255fc00c | |||
| 75032fd498 |
Generated
+536
-582
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() });
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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') ? [] : {},
|
||||
));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'] })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,57 @@ function makeBackendProxy(target) {
|
||||
};
|
||||
}
|
||||
|
||||
// Deps only reachable through swagger-ui-react (verified via `npm ls`). The
|
||||
// catch-all `vendor` chunk would otherwise eager-load them on first paint,
|
||||
// although the api-docs page is the only lazy route importing them.
|
||||
const SWAGGER_ONLY_DEPS = [
|
||||
'@babel/runtime-corejs3',
|
||||
'@scarf/scarf',
|
||||
'@swagger-api/apidom-',
|
||||
'@swaggerexpert/',
|
||||
'base64-js',
|
||||
'buffer',
|
||||
'classnames',
|
||||
'css.escape',
|
||||
'deep-extend',
|
||||
'dompurify',
|
||||
'fast-json-patch',
|
||||
'highlight.js',
|
||||
'highlightjs-vue',
|
||||
'ieee754',
|
||||
'immutable',
|
||||
'js-file-download',
|
||||
'js-yaml',
|
||||
'lodash',
|
||||
'lowlight',
|
||||
'neotraverse',
|
||||
'node-abort-controller',
|
||||
'openapi-path-templating',
|
||||
'openapi-server-url-templating',
|
||||
'prismjs',
|
||||
'prop-types',
|
||||
'ramda',
|
||||
'ramda-adjunct',
|
||||
'randexp',
|
||||
'react-copy-to-clipboard',
|
||||
'react-debounce-input',
|
||||
'react-immutable-proptypes',
|
||||
'react-immutable-pure-component',
|
||||
'react-inspector',
|
||||
'react-redux',
|
||||
'react-syntax-highlighter',
|
||||
'redux',
|
||||
'redux-immutable',
|
||||
'remarkable',
|
||||
'reselect',
|
||||
'serialize-error',
|
||||
'sha.js',
|
||||
'url-parse',
|
||||
'xml',
|
||||
'xml-but-prettier',
|
||||
'zenscroll',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
|
||||
resolve: {
|
||||
@@ -222,6 +273,7 @@ export default defineConfig({
|
||||
id.includes('/node_modules/swagger-ui-react/')
|
||||
|| id.includes('/node_modules/swagger-ui/')
|
||||
|| id.includes('/node_modules/swagger-client/')
|
||||
|| SWAGGER_ONLY_DEPS.some((dep) => id.includes(`/node_modules/${dep}/`))
|
||||
) return 'vendor-swagger';
|
||||
if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
|
||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user