forked from boos/BoosAPI
Merge pull request #50 from cita-777/codex/model-refresh-health
Codex/model refresh health
This commit is contained in:
@@ -79,7 +79,11 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
refreshed: true,
|
refreshed: true,
|
||||||
|
status: 'success',
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: '',
|
||||||
modelCount: 2,
|
modelCount: 2,
|
||||||
|
modelsPreview: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'],
|
||||||
tokenScanned: 0,
|
tokenScanned: 0,
|
||||||
discoveredByCredential: true,
|
discoveredByCredential: true,
|
||||||
});
|
});
|
||||||
@@ -95,4 +99,121 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
const tokenRows = await db.select().from(schema.tokenModelAvailability).all();
|
const tokenRows = await db.select().from(schema.tokenModelAvailability).all();
|
||||||
expect(tokenRows).toHaveLength(0);
|
expect(tokenRows).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks runtime health unhealthy when model discovery fails', async () => {
|
||||||
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
|
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
|
||||||
|
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-fail',
|
||||||
|
url: 'https://site-fail.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'fail-user',
|
||||||
|
accessToken: '',
|
||||||
|
apiToken: 'sk-invalid',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = await refreshModelsForAccount(account.id);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: account.id,
|
||||||
|
refreshed: true,
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
tokenScanned: 0,
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'unauthorized',
|
||||||
|
});
|
||||||
|
|
||||||
|
const latest = await db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, account.id))
|
||||||
|
.get();
|
||||||
|
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||||
|
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||||
|
expect(parsed.runtimeHealth?.source).toBe('model-discovery');
|
||||||
|
expect(parsed.runtimeHealth?.reason).toBe('模型获取失败,API Key 已无效');
|
||||||
|
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns structured result when account missing', async () => {
|
||||||
|
const result = await refreshModelsForAccount(9999);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: 9999,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'account_not_found',
|
||||||
|
errorMessage: '账号不存在',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'account_not_found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns structured result when site disabled', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-disabled',
|
||||||
|
url: 'https://site-disabled.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'disabled',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'disabled-user',
|
||||||
|
accessToken: 'session-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = await refreshModelsForAccount(account.id);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: account.id,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'skipped',
|
||||||
|
errorCode: 'site_disabled',
|
||||||
|
errorMessage: '站点已禁用',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'site_disabled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns structured result when account inactive', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-inactive',
|
||||||
|
url: 'https://site-inactive.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'inactive-user',
|
||||||
|
accessToken: 'session-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'disabled',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = await refreshModelsForAccount(account.id);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: account.id,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'skipped',
|
||||||
|
errorCode: 'adapter_or_status',
|
||||||
|
errorMessage: '平台不可用或账号未激活',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'adapter_or_status',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
|||||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||||
|
|
||||||
|
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||||
|
|
||||||
|
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
||||||
|
const lowered = message.toLowerCase();
|
||||||
|
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
||||||
|
if (lowered.includes('http 401') || lowered.includes('http 403')
|
||||||
|
|| lowered.includes('unauthorized') || lowered.includes('invalid')
|
||||||
|
|| lowered.includes('无权') || lowered.includes('未提供令牌')) return 'unauthorized';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string) {
|
||||||
|
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||||||
|
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||||||
|
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||||||
|
return fallback || '模型获取失败';
|
||||||
|
}
|
||||||
|
|
||||||
function isSiteDisabled(status?: string | null): boolean {
|
function isSiteDisabled(status?: string | null): boolean {
|
||||||
return (status || 'active') === 'disabled';
|
return (status || 'active') === 'disabled';
|
||||||
}
|
}
|
||||||
@@ -53,7 +71,16 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
|
return {
|
||||||
|
accountId,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'account_not_found',
|
||||||
|
errorMessage: '账号不存在',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'account_not_found',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = row.accounts;
|
const account = row.accounts;
|
||||||
@@ -76,11 +103,29 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSiteDisabled(site.status)) {
|
if (isSiteDisabled(site.status)) {
|
||||||
return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' };
|
return {
|
||||||
|
accountId,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'skipped',
|
||||||
|
errorCode: 'site_disabled',
|
||||||
|
errorMessage: '站点已禁用',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'site_disabled',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adapter || account.status !== 'active') {
|
if (!adapter || account.status !== 'active') {
|
||||||
return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' };
|
return {
|
||||||
|
accountId,
|
||||||
|
refreshed: false,
|
||||||
|
status: 'skipped',
|
||||||
|
errorCode: 'adapter_or_status',
|
||||||
|
errorMessage: '平台不可用或账号未激活',
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
reason: 'adapter_or_status',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||||
@@ -125,6 +170,11 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
let scannedTokenCount = 0;
|
let scannedTokenCount = 0;
|
||||||
let discoveredByCredential = false;
|
let discoveredByCredential = false;
|
||||||
const attemptedCredentials = new Set<string>();
|
const attemptedCredentials = new Set<string>();
|
||||||
|
const failureMessages: string[] = [];
|
||||||
|
const recordFailure = (err: unknown) => {
|
||||||
|
const message = (err as { message?: string })?.message || String(err || '');
|
||||||
|
if (message) failureMessages.push(message);
|
||||||
|
};
|
||||||
|
|
||||||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||||||
for (const modelName of models) {
|
for (const modelName of models) {
|
||||||
@@ -155,7 +205,8 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
recordFailure(err);
|
||||||
models = [];
|
models = [];
|
||||||
}
|
}
|
||||||
if (models.length === 0) return;
|
if (models.length === 0) return;
|
||||||
@@ -181,7 +232,8 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
recordFailure(err);
|
||||||
models = [];
|
models = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,32 +256,57 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
mergeDiscoveredModels(models, latencyMs);
|
mergeDiscoveredModels(models, latencyMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountModels.size > 0) {
|
if (accountModels.size === 0) {
|
||||||
const checkedAt = new Date().toISOString();
|
const firstMessage = failureMessages[0] || '';
|
||||||
await db.insert(schema.modelAvailability).values(
|
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
||||||
Array.from(accountModels).map((modelName) => ({
|
const errorMessage = buildModelFailureMessage(errorCode, firstMessage);
|
||||||
accountId: account.id,
|
await setAccountRuntimeHealth(account.id, {
|
||||||
modelName,
|
state: 'unhealthy',
|
||||||
available: true,
|
reason: errorMessage,
|
||||||
latencyMs: modelLatency.get(modelName) ?? null,
|
source: 'model-discovery',
|
||||||
checkedAt,
|
checkedAt: new Date().toISOString(),
|
||||||
})),
|
});
|
||||||
).run();
|
return {
|
||||||
|
accountId,
|
||||||
if (isApiKeyConnection(account)) {
|
refreshed: true,
|
||||||
await setAccountRuntimeHealth(account.id, {
|
status: 'failed',
|
||||||
state: 'healthy',
|
errorCode,
|
||||||
reason: '模型探测成功',
|
errorMessage,
|
||||||
source: 'model-discovery',
|
modelCount: 0,
|
||||||
checkedAt,
|
modelsPreview: [],
|
||||||
});
|
tokenScanned: scannedTokenCount,
|
||||||
}
|
discoveredByCredential,
|
||||||
|
discoveredApiToken: !!discoveredApiToken,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
await db.insert(schema.modelAvailability).values(
|
||||||
|
Array.from(accountModels).map((modelName) => ({
|
||||||
|
accountId: account.id,
|
||||||
|
modelName,
|
||||||
|
available: true,
|
||||||
|
latencyMs: modelLatency.get(modelName) ?? null,
|
||||||
|
checkedAt,
|
||||||
|
})),
|
||||||
|
).run();
|
||||||
|
|
||||||
|
await setAccountRuntimeHealth(account.id, {
|
||||||
|
state: 'healthy',
|
||||||
|
reason: '模型探测成功',
|
||||||
|
source: 'model-discovery',
|
||||||
|
checkedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelsPreview = Array.from(accountModels).slice(0, 10);
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
refreshed: true,
|
refreshed: true,
|
||||||
|
status: 'success',
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: '',
|
||||||
modelCount: accountModels.size,
|
modelCount: accountModels.size,
|
||||||
|
modelsPreview,
|
||||||
tokenScanned: scannedTokenCount,
|
tokenScanned: scannedTokenCount,
|
||||||
discoveredByCredential,
|
discoveredByCredential,
|
||||||
discoveredApiToken: !!discoveredApiToken,
|
discoveredApiToken: !!discoveredApiToken,
|
||||||
|
|||||||
@@ -352,6 +352,42 @@ export default function Accounts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatModelSuccess = (refresh: any) => {
|
||||||
|
const models = Array.isArray(refresh?.modelsPreview) ? refresh.modelsPreview : [];
|
||||||
|
const count = Number.isFinite(refresh?.modelCount) ? refresh.modelCount : models.length;
|
||||||
|
if (models.length === 0) return `已获取到模型(共 ${count} 个)`;
|
||||||
|
const preview = models.slice(0, 6).join('、');
|
||||||
|
const suffix = `(共 ${count} 个)`;
|
||||||
|
return `已获取到模型:${preview}${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatModelFailure = (refresh: any, messageFallback?: string) => {
|
||||||
|
const code = refresh?.errorCode;
|
||||||
|
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||||||
|
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||||||
|
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||||||
|
return messageFallback || refresh?.errorMessage || '模型获取失败';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckModels = async (accountId: number) => {
|
||||||
|
const key = `models-${accountId}`;
|
||||||
|
setActionLoading(s => ({ ...s, [key]: true }));
|
||||||
|
try {
|
||||||
|
const result = await api.checkModels(accountId);
|
||||||
|
const refresh = result?.refresh;
|
||||||
|
if (!refresh || refresh.status !== 'success') {
|
||||||
|
toast.error(formatModelFailure(refresh, result?.message));
|
||||||
|
} else {
|
||||||
|
toast.success(formatModelSuccess(refresh));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '模型获取失败');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(s => ({ ...s, [key]: false }));
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||||
@@ -1685,7 +1721,7 @@ export default function Accounts() {
|
|||||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
<button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||||
</button>
|
</button>
|
||||||
{capabilities.canCheckin && (
|
{capabilities.canCheckin && (
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { ToastProvider } from '../components/Toast.js';
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
import Accounts from './Accounts.js';
|
import Accounts from './Accounts.js';
|
||||||
|
|
||||||
const { apiMock } = vi.hoisted(() => ({
|
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||||
apiMock: {
|
apiMock: {
|
||||||
getAccounts: vi.fn(),
|
getAccounts: vi.fn(),
|
||||||
getSites: vi.fn(),
|
getSites: vi.fn(),
|
||||||
updateAccount: vi.fn(),
|
updateAccount: vi.fn(),
|
||||||
refreshAccountHealth: vi.fn(),
|
refreshAccountHealth: vi.fn(),
|
||||||
|
checkModels: vi.fn(),
|
||||||
|
},
|
||||||
|
toastMock: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
toast: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -17,6 +25,11 @@ vi.mock('../api.js', () => ({
|
|||||||
api: apiMock,
|
api: apiMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Toast.js', () => ({
|
||||||
|
ToastProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useToast: () => toastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
function collectText(node: ReactTestInstance): string {
|
||||||
return (node.children || []).map((child) => {
|
return (node.children || []).map((child) => {
|
||||||
if (typeof child === 'string') return child;
|
if (typeof child === 'string') return child;
|
||||||
@@ -89,4 +102,47 @@ describe('Accounts edit panel', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows model refresh toast on success', async () => {
|
||||||
|
apiMock.checkModels.mockResolvedValue({
|
||||||
|
refresh: {
|
||||||
|
status: 'success',
|
||||||
|
modelCount: 2,
|
||||||
|
modelsPreview: ['gpt-4', 'gpt-3.5'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const modelButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-info')
|
||||||
|
&& collectText(node).trim() === '模型'
|
||||||
|
));
|
||||||
|
expect(modelButtons.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await modelButtons[0]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
|
||||||
|
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user