fix: improve tester timeouts and telegram proxy config

This commit is contained in:
ciat-777
2026-03-14 16:18:43 +08:00
parent 201a806e51
commit 7bd48a3c6f
12 changed files with 369 additions and 6 deletions
+100
View File
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
function installPendingFetch() {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) return;
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
}));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('api proxy test timeout handling', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('keeps image generation proxy tests alive past the default 30 second timeout', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/images/generations',
requestKind: 'json',
jsonBody: {
model: 'gemini-imagen',
prompt: 'banana cat',
},
};
let settled = false;
const promise = api.proxyTest(payload);
const handled = promise
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(120_000);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected image generation proxy test to time out');
}
expect(result.error.message).toBe('请求超时(150s)');
});
it('still uses the default 30 second timeout for generic proxy tests', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
jsonBody: {
model: 'text-embedding-3-small',
input: 'hello',
},
};
const promise = api.proxyTest(payload).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(30_000);
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
});
});
+26 -3
View File
@@ -135,6 +135,17 @@ export type ProxyTestRequestEnvelope = {
multipartFiles?: ProxyTestMultipartFile[];
};
const DEFAULT_PROXY_TEST_TIMEOUT_MS = 30_000;
const LONG_RUNNING_PROXY_TEST_TIMEOUT_MS = 150_000;
function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) {
if (data.jobMode) return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/images/generations') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/images/edits') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/videos' && data.method === 'POST') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
return DEFAULT_PROXY_TEST_TIMEOUT_MS;
}
export type ProxyTestJobResponse = {
jobId: string;
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
@@ -425,13 +436,25 @@ export const api = {
getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`),
deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
startProxyTestJob: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy/jobs', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
proxyTest: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
testChat: (data: TestChatRequestPayload) =>
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import NotificationSettings from './NotificationSettings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getRuntimeSettings: vi.fn(),
updateRuntimeSettings: vi.fn(),
testNotification: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('NotificationSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramBotTokenMasked: '1234****token',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: https://your-proxy.example.com'
));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com');
await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存通知设置')
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/',
}));
} finally {
root?.unmount();
}
});
});
+17
View File
@@ -10,6 +10,7 @@ type RuntimeSettings = {
barkEnabled: boolean;
serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramApiBaseUrl: string;
telegramChatId: string;
smtpEnabled: boolean;
smtpHost: string;
@@ -32,6 +33,7 @@ export default function NotificationSettings() {
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
smtpEnabled: false,
smtpHost: '',
@@ -74,6 +76,7 @@ export default function NotificationSettings() {
barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
@@ -110,6 +113,7 @@ export default function NotificationSettings() {
barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
@@ -325,6 +329,19 @@ export default function NotificationSettings() {
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram API Base URL</div>
<input
value={runtime.telegramApiBaseUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramApiBaseUrl: e.target.value }))}
placeholder="例如: https://your-proxy.example.com"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
留空或使用默认值时直连官方 Telegram API;如需国内反代,可填写反代前缀。
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Chat ID</div>
<input