From d5560fb0361eebd07b7d1dc5fefbd68f35e5efa3 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 5 Apr 2026 20:09:34 +0800 Subject: [PATCH] [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 --- src/server/routes/api/sites.proxyUrl.test.ts | 78 +++++- src/server/routes/api/sites.ts | 13 +- src/server/services/platforms/sub2api.test.ts | 146 +++++++++++ src/server/services/platforms/sub2api.ts | 62 ++++- src/server/services/siteDetector.ts | 14 +- src/shared/siteInitializationPresets.js | 14 ++ src/shared/siteInitializationPresets.test.ts | 11 + src/shared/sitePrimaryUrl.d.ts | 15 ++ src/shared/sitePrimaryUrl.js | 107 ++++++++ src/shared/sitePrimaryUrl.test.ts | 77 ++++++ src/web/components/ModernSelect.tsx | 3 + src/web/pages/Sites.tsx | 98 +++++++- src/web/pages/sites.centered-modal.test.ts | 8 +- src/web/pages/sites.create-redirect.test.tsx | 43 ++-- src/web/pages/sites.detect-race.test.tsx | 229 ++++++++++++++++++ 15 files changed, 857 insertions(+), 61 deletions(-) create mode 100644 src/shared/sitePrimaryUrl.d.ts create mode 100644 src/shared/sitePrimaryUrl.js create mode 100644 src/shared/sitePrimaryUrl.test.ts create mode 100644 src/web/pages/sites.detect-race.test.tsx diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index 61f97d3..115f466 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -514,7 +514,7 @@ describe('sites proxy settings', () => { }); }); - it('canonicalizes create payload url and platform before persistence and conflict checks', async () => { + it('canonicalizes create payload url, strips known non-api api suffixes, and normalizes platform before persistence and conflict checks', async () => { const created = await app.inject({ method: 'POST', url: '/api/sites', @@ -528,7 +528,7 @@ describe('sites proxy settings', () => { expect(created.statusCode).toBe(200); expect(created.json()).toMatchObject({ name: 'Aliyun Generic OpenAI', - url: 'https://coding.dashscope.aliyuncs.com/v1', + url: 'https://coding.dashscope.aliyuncs.com', platform: 'openai', }); @@ -537,7 +537,7 @@ describe('sites proxy settings', () => { url: '/api/sites', payload: { name: 'Aliyun Generic OpenAI Duplicate', - url: 'https://coding.dashscope.aliyuncs.com/v1', + url: 'https://coding.dashscope.aliyuncs.com', platform: 'openai', }, }); @@ -546,7 +546,7 @@ describe('sites proxy settings', () => { expect((duplicate.json() as { error?: string }).error).toContain('already exists'); }); - it('canonicalizes update payload url and platform before saving', async () => { + it('canonicalizes update payload url, strips known non-api api suffixes, and normalizes platform before saving', async () => { const created = await app.inject({ method: 'POST', url: '/api/sites', @@ -569,7 +569,75 @@ describe('sites proxy settings', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ - url: 'https://coding.dashscope.aliyuncs.com/v1', + url: 'https://coding.dashscope.aliyuncs.com', + platform: 'openai', + }); + }); + + it('preserves /api-prefixed main site paths instead of auto-stripping them', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'API Path Site', + url: 'https://panel.example.com/api/v1/models', + platform: 'openai', + }, + }); + + expect(created.statusCode).toBe(200); + expect(created.json()).toMatchObject({ + url: 'https://panel.example.com/api/v1/models', + platform: 'openai', + }); + }); + + it('preserves known semantic paths like codex backend-api roots', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'Codex Site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }); + + expect(created.statusCode).toBe(200); + expect(created.json()).toMatchObject({ + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }); + }); + + it('returns canonical root urls for detect requests that hit known non-api api suffixes', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites/detect', + payload: { + url: 'https://api.openai.com/v1/messages', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + url: 'https://api.openai.com', + platform: 'openai', + }); + }); + + it('does not strip /api-prefixed paths from detect responses', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites/detect', + payload: { + url: 'https://api.openai.com/api/v1/models', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + url: 'https://api.openai.com/api/v1/models', platform: 'openai', }); }); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index e7236d6..7e27c23 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -3,7 +3,7 @@ import { db, schema } from '../../db/index.js'; import { getInsertedRowId } from '../../db/insertHelpers.js'; import { and, asc, eq, inArray } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; -import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; @@ -17,6 +17,7 @@ import { } from '../../contracts/siteRoutePayloads.js'; import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js'; import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js'; +import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.js'; function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { if (input === undefined || input === null) return null; @@ -93,9 +94,7 @@ type ErrorLike = { }; function normalizeCanonicalSiteUrl(value: string): string { - const trimmed = value.trim(); - const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`; - return normalizeSiteUrl(withScheme); + return analyzePrimarySiteUrl(value).persistedUrl; } function normalizeSitePlatform(value: string | undefined): string | null { @@ -520,7 +519,9 @@ export async function sitesRoutes(app: FastifyInstance) { const existingSites = await db.select().from(schema.sites).all(); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); - const canonicalUrl = normalizeCanonicalSiteUrl(url); + const analyzedPrimarySiteUrl = analyzePrimarySiteUrl(url); + const canonicalUrl = analyzedPrimarySiteUrl.persistedUrl; + const detectionUrl = analyzedPrimarySiteUrl.canonicalUrl || canonicalUrl; const canonicalPlatform = normalizeSitePlatform(platform); let detectedPlatform = canonicalPlatform; let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null; @@ -528,7 +529,7 @@ export async function sitesRoutes(app: FastifyInstance) { if (explicitInitializationPreset) { detectedPlatform = explicitInitializationPreset.platform; } else { - const detected = await detectSite(canonicalUrl); + const detected = await detectSite(detectionUrl); detectedPlatform = detected?.platform ?? null; responseInitializationPresetId = detected?.initializationPresetId || null; } diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts index 94e07d0..bcc257c 100644 --- a/src/server/services/platforms/sub2api.test.ts +++ b/src/server/services/platforms/sub2api.test.ts @@ -259,6 +259,64 @@ describe('Sub2ApiAdapter', () => { expect(models).toEqual(['gpt-4o', 'claude-3-opus']); }); + it('fetches gemini models via /v1beta/models when ai base url already targets gemini endpoint', async () => { + await startServer((req, res) => { + if (req.url === '/v1beta/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash' }, + { name: 'models/gemini-2.5-pro' }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(`${baseUrl}/v1beta`, 'gemini-key'); + expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']); + }); + + it('falls back to /v1beta/models when openai-compatible model endpoints are unavailable', async () => { + await startServer((req, res) => { + if (req.url === '/v1/models' || req.url === '/api/v1/models') { + res.writeHead(404).end(); + return; + } + if (req.url === '/v1beta/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash-lite' }, + { name: 'models/gemini-3-pro-preview' }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(baseUrl, 'gemini-key'); + expect(models).toEqual(['gemini-2.5-flash-lite', 'gemini-3-pro-preview']); + }); + + it('uses the api/v1 model endpoint directly when the ai base already includes /api/v1', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [{ id: 'gpt-4o-mini' }, { id: 'claude-3-5-sonnet' }], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(`${baseUrl}/api/v1`, 'jwt-token'); + expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']); + }); + it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => { await startServer((req, res) => { const auth = req.headers.authorization || ''; @@ -299,6 +357,94 @@ describe('Sub2ApiAdapter', () => { expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']); }); + it('discovers an api key for gemini /v1beta/models when session JWT cannot call the endpoint directly', async () => { + await startServer((req, res) => { + const auth = req.headers.authorization || ''; + if (req.url === '/v1beta/models' && auth === 'Bearer jwt-token') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + code: 401, + message: 'API key is required', + status: 'UNAUTHENTICATED', + }, + })); + return; + } + if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { + items: [ + { id: 1, key: 'sk-sub2-gemini', name: 'gemini', status: 'active' }, + ], + }, + })); + return; + } + if (req.url === '/v1beta/models' && auth === 'Bearer sk-sub2-gemini') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash' }, + { name: 'models/gemini-3.1-pro-preview' }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(`${baseUrl}/v1beta`, 'jwt-token'); + expect(models).toEqual(['gemini-2.5-flash', 'gemini-3.1-pro-preview']); + }); + + it('strips a bare antigravity suffix before listing api keys for jwt fallback', async () => { + await startServer((req, res) => { + const auth = req.headers.authorization || ''; + if (req.url === '/antigravity/v1beta/models' && auth === 'Bearer jwt-token') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + code: 401, + message: 'API key is required', + status: 'UNAUTHENTICATED', + }, + })); + return; + } + if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { + items: [ + { id: 1, key: 'sk-sub2-antigravity', name: 'gemini', status: 'active' }, + ], + }, + })); + return; + } + if (req.url === '/antigravity/v1beta/models' && auth === 'Bearer sk-sub2-antigravity') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash' }, + { name: 'models/gemini-2.5-pro' }, + ], + })); + return; + } + res.writeHead(404).end(); + }); + + const models = await adapter.getModels(`${baseUrl}/antigravity`, 'jwt-token'); + expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']); + }); + it('handles non-zero code as error in /api/v1/auth/me', async () => { await startServer((req, res) => { if (req.url === '/api/v1/auth/me') { diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts index df4fa11..8b66a29 100644 --- a/src/server/services/platforms/sub2api.ts +++ b/src/server/services/platforms/sub2api.ts @@ -436,12 +436,64 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { })(); const models = rawModels - .map((item: any) => (typeof item === 'string' ? item : item?.id)) + .map((item: any) => (typeof item === 'string' ? item : item?.id ?? item?.name)) .map((value: unknown) => String(value || '').trim()) + .map((value: string) => value.replace(/^models\//i, '')) .filter(Boolean); return Array.from(new Set(models)); } + private resolveModelEndpoints(baseUrl: string): string[] { + const normalizedBase = normalizeBaseUrl(baseUrl); + if (!normalizedBase) return []; + if (/\/models$/i.test(normalizedBase)) return [normalizedBase]; + if (/\/(?:antigravity\/)?v\d+(?:\.\d+)?(?:beta)?$/i.test(normalizedBase)) { + return [`${normalizedBase}/models`]; + } + if (/\/antigravity$/i.test(normalizedBase)) { + return [ + `${normalizedBase}/v1/models`, + `${normalizedBase}/v1beta/models`, + ]; + } + return [ + `${normalizedBase}/v1/models`, + `${normalizedBase}/api/v1/models`, + `${normalizedBase}/v1beta/models`, + `${normalizedBase}/antigravity/v1beta/models`, + ]; + } + + private resolveManagementBaseUrl(baseUrl: string): string { + let normalizedBase = normalizeBaseUrl(baseUrl); + if (!normalizedBase) return normalizedBase; + + const suffixes = [ + '/models', + '/antigravity', + '/antigravity/v1beta', + '/antigravity/v1', + '/api/v1', + '/v1beta', + '/v1', + ]; + + let changed = true; + while (changed) { + changed = false; + for (const suffix of suffixes) { + if (!normalizedBase.toLowerCase().endsWith(suffix)) continue; + const trimmed = normalizeBaseUrl(normalizedBase.slice(0, -suffix.length)); + if (!trimmed || trimmed === normalizedBase) continue; + normalizedBase = trimmed; + changed = true; + break; + } + } + + return normalizedBase; + } + private async listApiKeys(baseUrl: string, accessToken: string): Promise> { const endpoints = [ '/api/v1/keys?page=1&page_size=100', @@ -467,10 +519,9 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { const authToken = this.normalizeTokenKeyForCompare(token); if (!authToken) return []; - const endpoints = ['/v1/models', '/api/v1/models']; - for (const endpoint of endpoints) { + for (const url of this.resolveModelEndpoints(baseUrl)) { try { - const res = await this.fetchJson(`${baseUrl}${endpoint}`, { + const res = await this.fetchJson(url, { headers: { Authorization: `Bearer ${authToken}` }, }); const models = this.extractModelIds(res); @@ -679,11 +730,12 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { // --- Models: Standard OpenAI-compatible endpoint --- async getModels(baseUrl: string, apiToken: string): Promise { const normalizedBase = normalizeBaseUrl(baseUrl); + const managementBase = this.resolveManagementBaseUrl(normalizedBase); const directModels = await this.fetchModelsByToken(normalizedBase, apiToken); if (directModels.length > 0) return directModels; // Session JWT cannot access /v1/models directly; discover a user key first. - const discoveredApiToken = await this.getApiToken(normalizedBase, apiToken); + const discoveredApiToken = await this.getApiToken(managementBase, apiToken); if (!discoveredApiToken) return []; if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) { return []; diff --git a/src/server/services/siteDetector.ts b/src/server/services/siteDetector.ts index a81940e..785e942 100644 --- a/src/server/services/siteDetector.ts +++ b/src/server/services/siteDetector.ts @@ -1,18 +1,20 @@ import { detectPlatform } from './platforms/index.js'; import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js'; -import { stripTrailingSlashes } from './urlNormalization.js'; +import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js'; export async function detectSite(url: string) { - const normalizedUrl = stripTrailingSlashes(url); - const preset = detectSiteInitializationPreset(normalizedUrl); + const analyzed = analyzePrimarySiteUrl(url); + const detectionUrl = analyzed.canonicalUrl; + const persistedUrl = analyzed.persistedUrl || detectionUrl; + const preset = detectSiteInitializationPreset(detectionUrl); if (preset) { return { - url: normalizedUrl, + url: persistedUrl, platform: preset.platform, initializationPresetId: preset.id, }; } - const adapter = await detectPlatform(normalizedUrl); + const adapter = await detectPlatform(detectionUrl); if (!adapter) return null; - return { url: normalizedUrl, platform: adapter.platformName }; + return { url: persistedUrl, platform: adapter.platformName }; } diff --git a/src/shared/siteInitializationPresets.js b/src/shared/siteInitializationPresets.js index 9bce365..58f94ad 100644 --- a/src/shared/siteInitializationPresets.js +++ b/src/shared/siteInitializationPresets.js @@ -1,3 +1,5 @@ +import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js'; + function normalizeUrlCandidate(url) { return typeof url === 'string' ? url.trim() : ''; } @@ -298,5 +300,17 @@ export function detectSiteInitializationPreset(url, platform) { if (normalizedPlatform && preset.platform !== normalizedPlatform) continue; if (preset.matches(url)) return clonePreset(preset); } + + if (!normalizedPlatform) return null; + + const analyzed = analyzePrimarySiteUrl(url); + for (const preset of SITE_INITIALIZATION_PRESETS) { + if (preset.platform !== normalizedPlatform) continue; + if (!preset.defaultUrl) continue; + const presetAnalyzed = analyzePrimarySiteUrl(preset.defaultUrl); + if (!presetAnalyzed.persistedUrl) continue; + if (presetAnalyzed.persistedUrl === analyzed.persistedUrl) return clonePreset(preset); + } + return null; } diff --git a/src/shared/siteInitializationPresets.test.ts b/src/shared/siteInitializationPresets.test.ts index 3d97d6e..da2e899 100644 --- a/src/shared/siteInitializationPresets.test.ts +++ b/src/shared/siteInitializationPresets.test.ts @@ -190,4 +190,15 @@ describe('siteInitializationPresets', () => { platform: 'openai', }); }); + + it('reselects openai presets from canonicalized root urls when platform is already known', () => { + expect(detectSiteInitializationPreset('https://api.deepseek.com', 'openai')).toMatchObject({ + id: 'deepseek-openai', + platform: 'openai', + }); + expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com', 'openai')).toMatchObject({ + id: 'codingplan-openai', + platform: 'openai', + }); + }); }); diff --git a/src/shared/sitePrimaryUrl.d.ts b/src/shared/sitePrimaryUrl.d.ts new file mode 100644 index 0000000..22398e5 --- /dev/null +++ b/src/shared/sitePrimaryUrl.d.ts @@ -0,0 +1,15 @@ +export type PrimarySiteUrlAction = + | 'unchanged' + | 'auto_strip_known_api_suffix' + | 'preserve_api_path' + | 'preserve_semantic_path' + | 'preserve_unknown_path'; + +export type PrimarySiteUrlAnalysis = { + canonicalUrl: string; + persistedUrl: string; + matchedPath: string; + action: PrimarySiteUrlAction; +}; + +export declare function analyzePrimarySiteUrl(url: string | null | undefined): PrimarySiteUrlAnalysis; diff --git a/src/shared/sitePrimaryUrl.js b/src/shared/sitePrimaryUrl.js new file mode 100644 index 0000000..acf0e7d --- /dev/null +++ b/src/shared/sitePrimaryUrl.js @@ -0,0 +1,107 @@ +function normalizePathname(pathname) { + let normalized = typeof pathname === 'string' ? pathname.trim() : ''; + if (!normalized || normalized === '/') return '/'; + if (!normalized.startsWith('/')) normalized = `/${normalized}`; + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function parseUrlCandidate(url) { + const trimmed = typeof url === 'string' ? url.trim() : ''; + if (!trimmed) return null; + + const candidates = trimmed.includes('://') + ? [trimmed] + : [`https://${trimmed}`]; + + for (const candidate of candidates) { + try { + return new URL(candidate); + } catch {} + } + return null; +} + +const AUTO_STRIP_PRIMARY_SITE_PATHS = new Set([ + '/v1', + '/v1beta', + '/v1/models', + '/v1/chat/completions', + '/v1/responses', + '/v1/messages', + '/v1beta/models', +]); + +const SEMANTIC_PRIMARY_SITE_PATHS = new Set([ + '/backend-api/codex', + '/anthropic', + '/apps/anthropic', + '/api/anthropic', + '/api/coding/paas/v4', + '/v1beta/openai', +]); + +export function analyzePrimarySiteUrl(url) { + const parsed = parseUrlCandidate(url); + if (!parsed) { + const trimmed = typeof url === 'string' ? url.trim().replace(/\/+$/, '') : ''; + return { + canonicalUrl: trimmed, + persistedUrl: trimmed, + matchedPath: '', + action: 'unchanged', + }; + } + + parsed.search = ''; + parsed.hash = ''; + const matchedPath = normalizePathname(parsed.pathname); + const canonicalUrl = matchedPath === '/' + ? parsed.origin + : `${parsed.origin}${matchedPath}`; + + if (matchedPath === '/') { + return { + canonicalUrl, + persistedUrl: canonicalUrl, + matchedPath, + action: 'unchanged', + }; + } + + if (SEMANTIC_PRIMARY_SITE_PATHS.has(matchedPath)) { + return { + canonicalUrl, + persistedUrl: canonicalUrl, + matchedPath, + action: 'preserve_semantic_path', + }; + } + + if (AUTO_STRIP_PRIMARY_SITE_PATHS.has(matchedPath)) { + return { + canonicalUrl, + persistedUrl: parsed.origin, + matchedPath, + action: 'auto_strip_known_api_suffix', + }; + } + + if (matchedPath.startsWith('/api')) { + return { + canonicalUrl, + persistedUrl: canonicalUrl, + matchedPath, + action: 'preserve_api_path', + }; + } + + return { + canonicalUrl, + persistedUrl: canonicalUrl, + matchedPath, + action: 'preserve_unknown_path', + }; +} diff --git a/src/shared/sitePrimaryUrl.test.ts b/src/shared/sitePrimaryUrl.test.ts new file mode 100644 index 0000000..72d7fc2 --- /dev/null +++ b/src/shared/sitePrimaryUrl.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js'; + +describe('sitePrimaryUrl', () => { + it('returns unchanged for empty, nullish, and invalid inputs', () => { + expect(analyzePrimarySiteUrl('')).toMatchObject({ + canonicalUrl: '', + persistedUrl: '', + action: 'unchanged', + matchedPath: '', + }); + expect(analyzePrimarySiteUrl(null as unknown as string)).toMatchObject({ + canonicalUrl: '', + persistedUrl: '', + action: 'unchanged', + matchedPath: '', + }); + expect(analyzePrimarySiteUrl(undefined as unknown as string)).toMatchObject({ + canonicalUrl: '', + persistedUrl: '', + action: 'unchanged', + matchedPath: '', + }); + expect(analyzePrimarySiteUrl(' not a valid url/// ')).toMatchObject({ + canonicalUrl: 'not a valid url', + persistedUrl: 'not a valid url', + action: 'unchanged', + matchedPath: '', + }); + }); + + it('returns unchanged for root-only urls', () => { + expect(analyzePrimarySiteUrl('https://example.com/')).toMatchObject({ + canonicalUrl: 'https://example.com', + persistedUrl: 'https://example.com', + action: 'unchanged', + matchedPath: '/', + }); + }); + + it('auto-strips known non-api request suffixes to the host root', () => { + expect(analyzePrimarySiteUrl('https://api.openai.com/v1/messages?trace=1#frag')).toMatchObject({ + canonicalUrl: 'https://api.openai.com/v1/messages', + persistedUrl: 'https://api.openai.com', + action: 'auto_strip_known_api_suffix', + matchedPath: '/v1/messages', + }); + }); + + it('preserves api-prefixed paths and marks them as warnings', () => { + expect(analyzePrimarySiteUrl('api.example.com/api/v1/models')).toMatchObject({ + canonicalUrl: 'https://api.example.com/api/v1/models', + persistedUrl: 'https://api.example.com/api/v1/models', + action: 'preserve_api_path', + matchedPath: '/api/v1/models', + }); + }); + + it('preserves known semantic paths without warnings', () => { + expect(analyzePrimarySiteUrl('https://chatgpt.com/backend-api/codex/')).toMatchObject({ + canonicalUrl: 'https://chatgpt.com/backend-api/codex', + persistedUrl: 'https://chatgpt.com/backend-api/codex', + action: 'preserve_semantic_path', + matchedPath: '/backend-api/codex', + }); + }); + + it('preserves unknown non-api paths and marks them as warnings', () => { + expect(analyzePrimarySiteUrl('https://gateway.example.com/custom-base')).toMatchObject({ + canonicalUrl: 'https://gateway.example.com/custom-base', + persistedUrl: 'https://gateway.example.com/custom-base', + action: 'preserve_unknown_path', + matchedPath: '/custom-base', + }); + }); +}); diff --git a/src/web/components/ModernSelect.tsx b/src/web/components/ModernSelect.tsx index 14a74d1..363410b 100644 --- a/src/web/components/ModernSelect.tsx +++ b/src/web/components/ModernSelect.tsx @@ -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 (