[codex] normalize primary site URL handling (#424)

* normalize primary site url handling

* fix site create redirect test selector

* fix sub2api gemini model discovery and site detect race

* fix sub2api antigravity fallback and test selectors

* fix site modal wording test

* guard blur detect against manual platform changes
This commit is contained in:
Cita
2026-04-05 20:09:34 +08:00
committed by GitHub
parent df75a80bf9
commit d5560fb036
15 changed files with 857 additions and 61 deletions
+3
View File
@@ -14,6 +14,7 @@ type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
@@ -28,6 +29,7 @@ export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
@@ -112,6 +114,7 @@ export default function ModernSelect({
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
+90 -8
View File
@@ -35,6 +35,7 @@ import {
getSiteInitializationPreset,
listSiteInitializationPresets,
} from '../../shared/siteInitializationPresets.js';
import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js';
type SiteSubscriptionSummary = {
activeCount: number;
@@ -294,6 +295,22 @@ export default function Sites() {
() => getSiteInitializationPreset(selectedInitializationPresetId),
[selectedInitializationPresetId],
);
const primarySiteUrlAnalysis = useMemo(() => analyzePrimarySiteUrl(form.url), [form.url]);
const latestPrimarySiteUrlRef = useRef(form.url);
const latestPlatformRef = useRef(form.platform);
const latestInitializationPresetIdRef = useRef(selectedInitializationPresetId);
useEffect(() => {
latestPrimarySiteUrlRef.current = form.url;
}, [form.url]);
useEffect(() => {
latestPlatformRef.current = form.platform;
}, [form.platform]);
useEffect(() => {
latestInitializationPresetIdRef.current = selectedInitializationPresetId;
}, [selectedInitializationPresetId]);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
@@ -529,7 +546,7 @@ export default function Sites() {
const payload = {
name: form.name.trim(),
url: form.url.trim(),
url: primarySiteUrlAnalysis.persistedUrl || form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
initializationPresetId: selectedInitializationPresetId,
@@ -550,6 +567,13 @@ export default function Sites() {
if (action.kind === 'add') {
const created = await api.addSite(action.payload);
toast.success(`站点 "${payload.name}" 已添加`);
if (
primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix'
&& typeof created?.url === 'string'
&& created.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${created.url.trim()}`);
}
const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) {
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
@@ -567,8 +591,15 @@ export default function Sites() {
});
}
} else {
await api.updateSite(action.id, action.payload);
const updated = await api.updateSite(action.id, action.payload);
toast.success(`站点 "${payload.name}" 已更新`);
if (
primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix'
&& typeof updated?.url === 'string'
&& updated.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${updated.url.trim()}`);
}
}
closeEditor();
await load();
@@ -684,22 +715,48 @@ export default function Sites() {
};
const handleDetect = async () => {
if (!form.url.trim()) {
const requestedUrl = form.url.trim();
const requestedPlatform = form.platform.trim();
const requestedInitializationPresetId = selectedInitializationPresetId;
if (!requestedUrl) {
toast.error('请先输入 URL');
return;
}
const requestedPrimarySiteUrl = analyzePrimarySiteUrl(requestedUrl);
setDetecting(true);
try {
const result = await api.detectSite(form.url.trim());
const result = await api.detectSite(requestedUrl);
if (
latestPrimarySiteUrlRef.current.trim() !== requestedUrl
|| latestPlatformRef.current.trim() !== requestedPlatform
|| latestInitializationPresetIdRef.current !== requestedInitializationPresetId
) {
return;
}
if (result?.platform) {
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
setForm((prev) => ({ ...prev, platform: result.platform }));
setForm((prev) => ({
...prev,
platform: result.platform,
url: requestedPrimarySiteUrl.action === 'auto_strip_known_api_suffix'
&& typeof result?.url === 'string'
&& result.url.trim()
? result.url.trim()
: prev.url,
}));
setSelectedInitializationPresetId((current) => {
if (detectedPreset) return detectedPreset.id;
const activePreset = getSiteInitializationPreset(current);
if (activePreset && activePreset.platform !== result.platform) return null;
return current;
});
if (
requestedPrimarySiteUrl.action === 'auto_strip_known_api_suffix'
&& typeof result?.url === 'string'
&& result.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${result.url.trim()}`);
}
toast.success(
detectedPreset
? `检测到平台: ${result.platform}(${detectedPreset.label})`
@@ -1035,7 +1092,7 @@ export default function Sites() {
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
<input
data-testid="site-primary-url-input"
placeholder="站点 URL(面板/登录/签到地址,如 https://console.example.com)"
placeholder="准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
@@ -1064,6 +1121,7 @@ export default function Sites() {
}}
>
<ModernSelect
data-testid="site-platform-select"
value={platformSelectValue}
onChange={(value) => {
if (value.startsWith('preset:')) {
@@ -1109,8 +1167,32 @@ export default function Sites() {
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
请填写准确的主站点 URL。这里填写主站点/面板/登录地址,用于登录、签到、面板接口和系统访问令牌管理;不要把 OpenAI/Gemini 请求路径直接填到主站点 URL;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
</div>
{primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-info animate-scale-in">
<div className="alert-title">检测到常见 API 路径后缀</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
保存或自动检测时会将主站点 URL 规范化为 {primarySiteUrlAnalysis.persistedUrl}。
</div>
</div>
) : null}
{primarySiteUrlAnalysis.action === 'preserve_api_path' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">请确认主站点 URL</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
当前 URL 含 /api 路径,将原样保留。请确认这就是准确的主站点 URL;如果这是 API 请求地址,请填到下方的 API 请求地址池。
</div>
</div>
) : null}
{primarySiteUrlAnalysis.action === 'preserve_unknown_path' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">请确认主站点 URL</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
当前 URL 含额外路径,将原样保留。请确认这就是准确的主站点 URL;如果这是 API 请求地址,请填到下方的 API 请求地址池。
</div>
</div>
) : null}
<div
style={{
display: 'flex',
@@ -1153,7 +1235,7 @@ export default function Sites() {
>
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<input
placeholder="API 请求地址(如 https://api.example.com)"
placeholder="API 请求地址(如 https://api.nih.cc)"
value={endpoint.url}
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
+4 -4
View File
@@ -16,12 +16,12 @@ describe('Sites centered modal adoption', () => {
expect(source).toContain('API 请求地址池');
expect(source).toContain('+ 添加 API 地址');
expect(source).toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
expect(source).toContain('API 请求地址(如 https://api.example.com)');
expect(source).toContain('准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)');
expect(source).toContain('API 请求地址(如 https://api.nih.cc)');
expect(source).toContain('label="API 请求地址"');
expect(source).toContain('API 地址: {buildSiteApiEndpointSummary(site)}');
expect(source).not.toContain('nih.cc');
expect(source).not.toContain('api.nih.cc');
expect(source).not.toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
expect(source).not.toContain('API 请求地址(如 https://api.example.com)');
expect(source).not.toContain('AI 请求地址池');
expect(source).not.toContain('+ 添加 AI 地址');
expect(source).not.toContain('label="AI 请求地址"');
+16 -27
View File
@@ -40,6 +40,15 @@ function findPrimarySiteUrlInput(root: ReactTestRenderer) {
));
}
function findClickableButtonByText(root: ReactTestRenderer, label: string) {
return root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
&& collectText(node).includes(label)
));
}
function LocationProbe() {
const location = useLocation();
return <div>{`${location.pathname}${location.search}`}</div>;
@@ -106,34 +115,14 @@ async function createSiteAndClickModalChoice(
});
await flushMicrotasks();
// Find the created-site modal and click the appropriate button
const modalContent = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-content')
&& collectText(node).includes('站点创建成功')
));
const modalButtons = modalContent.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
));
const targetButton = choice === 'session'
? findClickableButtonByText(root, createdSite.platform === 'codex' ? '添加 OAuth 连接' : '添加账号(用户名密码登录)')
: choice === 'apikey'
? findClickableButtonByText(root, '添加 API Key')
: findClickableButtonByText(root, '稍后配置');
// Find the button based on choice
let targetButton;
if (choice === 'session') {
const expectedSessionLabel = createdSite.platform === 'codex'
? '添加 OAuth 连接'
: '添加账号(用户名密码登录)';
targetButton = modalButtons.find((btn) => collectText(btn).includes(expectedSessionLabel));
} else if (choice === 'apikey') {
targetButton = modalButtons.find((btn) => collectText(btn).includes('添加 API Key'));
} else {
targetButton = modalButtons.find((btn) => collectText(btn).includes('稍后配置'));
}
expect(targetButton).toBeTruthy();
await act(async () => {
targetButton!.props.onClick();
targetButton.props.onClick();
});
await flushMicrotasks();
@@ -574,7 +563,7 @@ describe('Sites create redirect', () => {
{
id: 36,
name: 'DeepSeek Official',
url: 'https://api.deepseek.com/v1',
url: 'https://api.deepseek.com',
platform: 'openai',
status: 'active',
},
+229
View File
@@ -0,0 +1,229 @@
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Sites from './Sites.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
detectSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: ReactNode }) => children,
useToast: () => toastMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findPrimarySiteUrlInput(root: ReactTestRenderer) {
return root.root.find((node) => (
node.type === 'input'
&& node.props['data-testid'] === 'site-primary-url-input'
));
}
function findPlatformSelect(root: ReactTestRenderer) {
return root.root.findByProps({ 'data-testid': 'site-platform-select' });
}
describe('Sites detect race handling', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('ignores stale detect results after the operator edits the primary site url', async () => {
const pendingDetect = deferred<{
platform: string;
url: string;
initializationPresetId: string | null;
}>();
apiMock.detectSite.mockReturnValueOnce(pendingDetect.promise);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const openAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
openAddButton.props.onClick();
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://stale.example.com/v1' } });
});
await flushMicrotasks();
const detectButton = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-ghost')
&& collectText(node).trim() === '自动检测'
)).at(-1);
expect(detectButton).toBeTruthy();
await act(async () => {
void detectButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.detectSite).toHaveBeenCalledWith('https://stale.example.com/v1');
const latestUrlInput = findPrimarySiteUrlInput(root);
await act(async () => {
latestUrlInput.props.onChange({ target: { value: 'https://fresh.example.com' } });
});
await flushMicrotasks();
await act(async () => {
pendingDetect.resolve({
platform: 'openai',
url: 'https://stale.example.com',
initializationPresetId: 'codingplan-openai',
});
await pendingDetect.promise;
});
await flushMicrotasks();
expect(findPrimarySiteUrlInput(root).props.value).toBe('https://fresh.example.com');
expect(findPlatformSelect(root).props.value).toBe('');
expect(toastMock.info).not.toHaveBeenCalledWith(expect.stringContaining('https://stale.example.com'));
expect(toastMock.success).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('ignores blur-started detect results after the operator manually selects a platform', async () => {
const pendingDetect = deferred<{
platform: string;
url: string;
initializationPresetId: string | null;
}>();
apiMock.detectSite.mockReturnValueOnce(pendingDetect.promise);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const openAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
openAddButton.props.onClick();
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://blur.example.com/v1' } });
});
await flushMicrotasks();
await act(async () => {
urlInput.props.onBlur();
});
await flushMicrotasks();
expect(apiMock.detectSite).toHaveBeenCalledWith('https://blur.example.com/v1');
const platformSelect = findPlatformSelect(root);
await act(async () => {
platformSelect.props.onChange('claude');
});
await flushMicrotasks();
await act(async () => {
pendingDetect.resolve({
platform: 'openai',
url: 'https://blur.example.com',
initializationPresetId: 'codingplan-openai',
});
await pendingDetect.promise;
});
await flushMicrotasks();
expect(findPlatformSelect(root).props.value).toBe('claude');
expect(toastMock.info).not.toHaveBeenCalledWith(expect.stringContaining('https://blur.example.com'));
expect(toastMock.success).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});