mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-08 12:18:46 +00:00
fix(image-analysis): preview backend status safely
This commit is contained in:
@@ -150,7 +150,7 @@ The dashboard provides visual management for all account types:
|
|||||||
> **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed.
|
> **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed.
|
||||||
> Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly.
|
> Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly.
|
||||||
|
|
||||||
> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the active backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON.
|
> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the configured backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON.
|
||||||
|
|
||||||
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
|
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
|
||||||
|
|
||||||
|
|||||||
+44
-1
@@ -1013,12 +1013,55 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
const imageAnalysisEnv = getImageAnalysisHookEnv({
|
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||||
profileName: profileInfo.name,
|
profileName: profileInfo.name,
|
||||||
profileType: profileInfo.type,
|
profileType: profileInfo.type,
|
||||||
settings,
|
settings,
|
||||||
cliproxyBridge,
|
cliproxyBridge,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
||||||
|
if (
|
||||||
|
resolvedTarget === 'claude' &&
|
||||||
|
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
|
||||||
|
imageAnalysisProvider
|
||||||
|
) {
|
||||||
|
const verboseProxyLaunch =
|
||||||
|
remainingArgs.includes('--verbose') ||
|
||||||
|
remainingArgs.includes('-v') ||
|
||||||
|
targetRemainingArgs.includes('--verbose') ||
|
||||||
|
targetRemainingArgs.includes('-v');
|
||||||
|
|
||||||
|
if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) {
|
||||||
|
console.error(
|
||||||
|
info(
|
||||||
|
`Image analysis via ${imageAnalysisProvider} is configured, but CLIProxy auth is missing. This session will use native Read. Run "ccs ${imageAnalysisProvider} --auth" to enable it.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
imageAnalysisEnv = {
|
||||||
|
...imageAnalysisEnv,
|
||||||
|
CCS_CURRENT_PROVIDER: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const ensureServiceResult = await ensureCliproxyService(
|
||||||
|
CLIPROXY_DEFAULT_PORT,
|
||||||
|
verboseProxyLaunch
|
||||||
|
);
|
||||||
|
if (!ensureServiceResult.started) {
|
||||||
|
console.error(
|
||||||
|
warn(
|
||||||
|
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
imageAnalysisEnv = {
|
||||||
|
...imageAnalysisEnv,
|
||||||
|
CCS_CURRENT_PROVIDER: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||||
const globalEnvConfig = getGlobalEnvConfig();
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||||
|
|||||||
@@ -292,6 +292,17 @@ function resolveBackend(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim());
|
||||||
|
const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim());
|
||||||
|
if (hasDirectAnthropicApiKey && !hasBaseUrl) {
|
||||||
|
return {
|
||||||
|
backendId: null,
|
||||||
|
backendDisplayName: null,
|
||||||
|
resolutionSource: 'unresolved',
|
||||||
|
reason: 'Direct Anthropic settings profiles use native file access unless explicitly mapped.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackBackend = normalizeImageAnalysisBackendId(
|
const fallbackBackend = normalizeImageAnalysisBackendId(
|
||||||
config.fallback_backend,
|
config.fallback_backend,
|
||||||
Object.keys(config.provider_models)
|
Object.keys(config.provider_models)
|
||||||
@@ -323,7 +334,10 @@ export function resolveImageAnalysisStatus(
|
|||||||
? (config.provider_models[resolution.backendId] ?? null)
|
? (config.provider_models[resolution.backendId] ?? null)
|
||||||
: null;
|
: null;
|
||||||
const shouldPersistHook =
|
const shouldPersistHook =
|
||||||
config.enabled && context.profileType !== 'default' && context.profileType !== 'account';
|
config.enabled &&
|
||||||
|
context.profileType !== 'default' &&
|
||||||
|
context.profileType !== 'account' &&
|
||||||
|
Boolean(resolution.backendId && model);
|
||||||
|
|
||||||
let status: ImageAnalysisStatusCode = 'active';
|
let status: ImageAnalysisStatusCode = 'active';
|
||||||
let reason = resolution.reason;
|
let reason = resolution.reason;
|
||||||
@@ -353,8 +367,16 @@ export function resolveImageAnalysisStatus(
|
|||||||
(!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken)
|
(!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken)
|
||||||
) {
|
) {
|
||||||
status = 'attention';
|
status = 'attention';
|
||||||
reason =
|
if (!context.cliproxyBridge.usesCurrentTarget && !context.cliproxyBridge.usesCurrentAuthToken) {
|
||||||
'Active, but runtime uses the current CLIProxy target instead of the stale route saved in this profile.';
|
reason =
|
||||||
|
'Runtime uses the current CLIProxy route and auth token instead of the saved values in this profile.';
|
||||||
|
} else if (!context.cliproxyBridge.usesCurrentTarget) {
|
||||||
|
reason =
|
||||||
|
'Runtime uses the current CLIProxy route instead of the saved route in this profile.';
|
||||||
|
} else {
|
||||||
|
reason =
|
||||||
|
'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.';
|
||||||
|
}
|
||||||
} else if (resolution.resolutionSource === 'profile-backend') {
|
} else if (resolution.resolutionSource === 'profile-backend') {
|
||||||
status = 'mapped';
|
status = 'mapped';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,13 @@ function resolveImageAnalysisStatusForProfile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) {
|
||||||
|
const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings);
|
||||||
|
const settingsPath = resolveSettingsPath(profileOrVariant);
|
||||||
|
|
||||||
|
return resolveImageAnalysisStatusForProfile(profileOrVariant, normalizedSettings, settingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
||||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
@@ -428,6 +435,29 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON
|
||||||
|
*/
|
||||||
|
router.post('/:profile/image-analysis-status', (req: Request, res: Response): void => {
|
||||||
|
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { profile } = req.params;
|
||||||
|
const { settings } = req.body;
|
||||||
|
|
||||||
|
if (!settings || typeof settings !== 'object') {
|
||||||
|
res.status(400).json({ error: 'settings object is required in request body' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
imageAnalysisStatus: resolvePreviewImageAnalysisStatus(profile, settings as Settings),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
respondInternalError(res, error, 'Internal server error.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Required env vars for CLIProxy providers to function */
|
/** Required env vars for CLIProxy providers to function */
|
||||||
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,17 @@ describe('image-analysis-backend-resolver', () => {
|
|||||||
expect(status.resolutionSource).toBe('copilot-alias');
|
expect(status.resolutionSource).toBe('copilot-alias');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the fallback backend for an unmapped settings profile', () => {
|
it('uses the fallback backend for an unmapped third-party settings profile', () => {
|
||||||
const status = resolveImageAnalysisStatus(
|
const status = resolveImageAnalysisStatus(
|
||||||
{
|
{
|
||||||
profileName: 'glm',
|
profileName: 'glm',
|
||||||
profileType: 'settings',
|
profileType: 'settings',
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'glm-test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DEFAULT_IMAGE_ANALYSIS_CONFIG
|
DEFAULT_IMAGE_ANALYSIS_CONFIG
|
||||||
);
|
);
|
||||||
@@ -59,6 +65,27 @@ describe('image-analysis-backend-resolver', () => {
|
|||||||
expect(status.model).toBe('gemini-2.5-flash');
|
expect(status.model).toBe('gemini-2.5-flash');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => {
|
||||||
|
const status = resolveImageAnalysisStatus(
|
||||||
|
{
|
||||||
|
profileName: 'claude-direct',
|
||||||
|
profileType: 'settings',
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: 'anthropic-test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DEFAULT_IMAGE_ANALYSIS_CONFIG
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status.supported).toBe(false);
|
||||||
|
expect(status.backendId).toBeNull();
|
||||||
|
expect(status.status).toBe('skipped');
|
||||||
|
expect(status.shouldPersistHook).toBe(false);
|
||||||
|
expect(status.reason).toContain('native file access');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses explicit profile_backends overrides for custom aliases', () => {
|
it('uses explicit profile_backends overrides for custom aliases', () => {
|
||||||
const config: ImageAnalysisConfig = {
|
const config: ImageAnalysisConfig = {
|
||||||
...DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
...DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||||
@@ -86,6 +113,12 @@ describe('image-analysis-backend-resolver', () => {
|
|||||||
{
|
{
|
||||||
profileName: 'glm',
|
profileName: 'glm',
|
||||||
profileType: 'settings',
|
profileType: 'settings',
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'glm-test-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
hookInstalled: false,
|
hookInstalled: false,
|
||||||
sharedHookInstalled: true,
|
sharedHookInstalled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,33 @@ describe('settings-routes image-analysis status', () => {
|
|||||||
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
|
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps direct Anthropic settings profiles on native read diagnostics', async () => {
|
||||||
|
writeJson(path.join(tempHome, '.ccs', 'claude-direct.settings.json'), {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: 'anthropic-test-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/claude-direct/raw`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
imageAnalysisStatus: {
|
||||||
|
status: string;
|
||||||
|
backendId: string | null;
|
||||||
|
shouldPersistHook: boolean;
|
||||||
|
runtimePath: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.imageAnalysisStatus.status).toBe('skipped');
|
||||||
|
expect(body.imageAnalysisStatus.backendId).toBeNull();
|
||||||
|
expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false);
|
||||||
|
expect(body.imageAnalysisStatus.runtimePath).toBeNull();
|
||||||
|
expect(body.imageAnalysisStatus.reason).toContain('native file access');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns explicit mapped status for custom aliases', async () => {
|
it('returns explicit mapped status for custom aliases', async () => {
|
||||||
writeJson(path.join(tempHome, '.ccs', 'config.yaml'), {
|
writeJson(path.join(tempHome, '.ccs', 'config.yaml'), {
|
||||||
version: 11,
|
version: 11,
|
||||||
@@ -183,4 +210,35 @@ describe('settings-routes image-analysis status', () => {
|
|||||||
expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath);
|
expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath);
|
||||||
expect(body.imageAnalysisStatus.hookInstalled).toBe(true);
|
expect(body.imageAnalysisStatus.hookInstalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('previews image-analysis status from unsaved editor settings', async () => {
|
||||||
|
writeProfileSettings(tempHome, 'glm', {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_API_KEY: 'glm-test-key',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/glm/image-analysis-status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'preview-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
imageAnalysisStatus: {
|
||||||
|
backendId: string | null;
|
||||||
|
resolutionSource: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.imageAnalysisStatus.backendId).toBe('ghcp');
|
||||||
|
expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { ImageAnalysisStatus } from '@/lib/api-client';
|
|||||||
|
|
||||||
interface ImageAnalysisStatusSectionProps {
|
interface ImageAnalysisStatusSectionProps {
|
||||||
status?: ImageAnalysisStatus | null;
|
status?: ImageAnalysisStatus | null;
|
||||||
|
source?: 'saved' | 'editor';
|
||||||
|
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBadge(status: ImageAnalysisStatus | null | undefined): {
|
function getBadge(status: ImageAnalysisStatus | null | undefined): {
|
||||||
@@ -13,7 +15,7 @@ function getBadge(status: ImageAnalysisStatus | null | undefined): {
|
|||||||
} {
|
} {
|
||||||
switch (status?.status) {
|
switch (status?.status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
return { label: 'Ready', variant: 'default' };
|
return { label: 'Configured', variant: 'default' };
|
||||||
case 'mapped':
|
case 'mapped':
|
||||||
return { label: 'Saved mapping', variant: 'secondary' };
|
return { label: 'Saved mapping', variant: 'secondary' };
|
||||||
case 'attention':
|
case 'attention':
|
||||||
@@ -36,28 +38,32 @@ function getSummary(status: ImageAnalysisStatus): string {
|
|||||||
case 'disabled':
|
case 'disabled':
|
||||||
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
|
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
|
||||||
case 'mapped':
|
case 'mapped':
|
||||||
return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`;
|
return `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`;
|
||||||
case 'attention':
|
case 'attention':
|
||||||
return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`;
|
return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`;
|
||||||
case 'hook-missing':
|
case 'hook-missing':
|
||||||
return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`;
|
return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`;
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return status.reason || 'Skipped for this profile.';
|
return status.reason || 'Skipped for this profile.';
|
||||||
case 'active':
|
case 'active':
|
||||||
default:
|
default:
|
||||||
if (status.resolutionSource === 'cliproxy-bridge') {
|
if (status.resolutionSource === 'cliproxy-bridge') {
|
||||||
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.resolutionSource === 'fallback-backend') {
|
if (status.resolutionSource === 'fallback-backend') {
|
||||||
return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeLine(status: ImageAnalysisStatus): string {
|
function getRuntimeLine(status: ImageAnalysisStatus): string {
|
||||||
|
if (status.status === 'hook-missing') {
|
||||||
|
return 'Read -> native file access (hook install required)';
|
||||||
|
}
|
||||||
|
|
||||||
if (!status.runtimePath) {
|
if (!status.runtimePath) {
|
||||||
return 'Read -> native file access';
|
return 'Read -> native file access';
|
||||||
}
|
}
|
||||||
@@ -65,6 +71,25 @@ function getRuntimeLine(status: ImageAnalysisStatus): string {
|
|||||||
return `Read -> image-analysis hook -> ${status.runtimePath}`;
|
return `Read -> image-analysis hook -> ${status.runtimePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusContext(
|
||||||
|
source: 'saved' | 'editor',
|
||||||
|
previewState: ImageAnalysisStatusSectionProps['previewState']
|
||||||
|
): string {
|
||||||
|
if (previewState === 'invalid') {
|
||||||
|
return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewState === 'refreshing') {
|
||||||
|
return 'Refreshing the live preview from the current editor state.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'editor') {
|
||||||
|
return 'Live preview from the current editor state. Save to persist these backend and hook changes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.';
|
||||||
|
}
|
||||||
|
|
||||||
function getPersistenceLine(status: ImageAnalysisStatus): string {
|
function getPersistenceLine(status: ImageAnalysisStatus): string {
|
||||||
if (!status.shouldPersistHook || !status.persistencePath) {
|
if (!status.shouldPersistHook || !status.persistencePath) {
|
||||||
return 'Not persisted for this profile type';
|
return 'Not persisted for this profile type';
|
||||||
@@ -77,7 +102,11 @@ function getPersistenceLine(status: ImageAnalysisStatus): string {
|
|||||||
return `${status.persistencePath} hook missing`;
|
return `${status.persistencePath} hook missing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) {
|
export function ImageAnalysisStatusSection({
|
||||||
|
status,
|
||||||
|
source = 'saved',
|
||||||
|
previewState = 'saved',
|
||||||
|
}: ImageAnalysisStatusSectionProps) {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
|
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
|
||||||
@@ -101,7 +130,7 @@ export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectio
|
|||||||
<h3 className="text-sm font-semibold">Image-analysis backend</h3>
|
<h3 className="text-sm font-semibold">Image-analysis backend</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Derived runtime status. This section is not written into the JSON editor above.
|
{getStatusContext(source, previewState)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={badge.variant} className="shrink-0 text-[11px]">
|
<Badge variant={badge.variant} className="shrink-0 text-[11px]">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useDeferredValue } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
@@ -107,6 +107,63 @@ export function ProfileEditor({
|
|||||||
return Object.keys(localEdits).length > 0;
|
return Object.keys(localEdits).length > 0;
|
||||||
}, [rawJsonEdits, localEdits, settings]);
|
}, [rawJsonEdits, localEdits, settings]);
|
||||||
|
|
||||||
|
const deferredPreviewJson = useDeferredValue(computedRawJsonContent);
|
||||||
|
const previewSettings = useMemo((): Settings | null => {
|
||||||
|
if (!computedHasChanges || !computedIsRawJsonValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(deferredPreviewJson) as Settings;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [computedHasChanges, computedIsRawJsonValid, deferredPreviewJson]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: previewStatusResponse,
|
||||||
|
isFetching: isPreviewStatusFetching,
|
||||||
|
isError: isPreviewStatusError,
|
||||||
|
} = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({
|
||||||
|
queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson],
|
||||||
|
enabled: previewSettings !== null,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/settings/${profileName}/image-analysis-status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ settings: previewSettings }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to preview image-analysis status: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageAnalysisStatus =
|
||||||
|
computedHasChanges && computedIsRawJsonValid && !isPreviewStatusError
|
||||||
|
? (previewStatusResponse?.imageAnalysisStatus ?? data?.imageAnalysisStatus)
|
||||||
|
: data?.imageAnalysisStatus;
|
||||||
|
const imageAnalysisStatusSource =
|
||||||
|
computedHasChanges &&
|
||||||
|
computedIsRawJsonValid &&
|
||||||
|
!isPreviewStatusError &&
|
||||||
|
previewStatusResponse?.imageAnalysisStatus
|
||||||
|
? 'editor'
|
||||||
|
: 'saved';
|
||||||
|
const imageAnalysisStatusPreviewState = !computedHasChanges
|
||||||
|
? 'saved'
|
||||||
|
: !computedIsRawJsonValid
|
||||||
|
? 'invalid'
|
||||||
|
: isPreviewStatusError
|
||||||
|
? 'saved'
|
||||||
|
: isPreviewStatusFetching && !previewStatusResponse?.imageAnalysisStatus
|
||||||
|
? 'refreshing'
|
||||||
|
: 'preview';
|
||||||
|
|
||||||
// Check for missing required fields (informational warning)
|
// Check for missing required fields (informational warning)
|
||||||
const missingRequiredFields = useMemo(() => {
|
const missingRequiredFields = useMemo(() => {
|
||||||
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
@@ -254,7 +311,9 @@ export function ProfileEditor({
|
|||||||
isRawJsonValid={computedIsRawJsonValid}
|
isRawJsonValid={computedIsRawJsonValid}
|
||||||
rawJsonEdits={rawJsonEdits}
|
rawJsonEdits={rawJsonEdits}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
imageAnalysisStatus={data?.imageAnalysisStatus}
|
imageAnalysisStatus={imageAnalysisStatus}
|
||||||
|
imageAnalysisStatusSource={imageAnalysisStatusSource}
|
||||||
|
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
|
||||||
onChange={handleRawJsonChange}
|
onChange={handleRawJsonChange}
|
||||||
missingRequiredFields={missingRequiredFields}
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface RawEditorSectionProps {
|
|||||||
rawJsonEdits: string | null;
|
rawJsonEdits: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
||||||
|
imageAnalysisStatusSource?: 'saved' | 'editor';
|
||||||
|
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
missingRequiredFields?: string[];
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,8 @@ export function RawEditorSection({
|
|||||||
rawJsonEdits,
|
rawJsonEdits,
|
||||||
settings,
|
settings,
|
||||||
imageAnalysisStatus,
|
imageAnalysisStatus,
|
||||||
|
imageAnalysisStatusSource = 'saved',
|
||||||
|
imageAnalysisStatusPreviewState = 'saved',
|
||||||
onChange,
|
onChange,
|
||||||
missingRequiredFields = [],
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
@@ -80,7 +84,11 @@ export function RawEditorSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-6 mb-4">
|
<div className="mx-6 mb-4">
|
||||||
<ImageAnalysisStatusSection status={imageAnalysisStatus} />
|
<ImageAnalysisStatusSection
|
||||||
|
status={imageAnalysisStatus}
|
||||||
|
source={imageAnalysisStatusSource}
|
||||||
|
previewState={imageAnalysisStatusPreviewState}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Global Env Indicator */}
|
{/* Global Env Indicator */}
|
||||||
<div className="mx-6 mb-4">
|
<div className="mx-6 mb-4">
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@tests/setup/test-utils';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section';
|
|
||||||
import type { ImageAnalysisStatus } from '@/lib/api-client';
|
import type { ImageAnalysisStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
vi.mock('@/components/shared/code-editor', () => ({
|
||||||
|
CodeEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||||
|
<textarea
|
||||||
|
aria-label="raw config editor"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/profiles/editor/header-section', () => ({
|
||||||
|
HeaderSection: () => <div data-testid="profile-editor-header" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/profiles/editor/friendly-ui-section', () => ({
|
||||||
|
FriendlyUISection: () => <div data-testid="profile-editor-friendly-ui" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/shared/confirm-dialog', () => ({
|
||||||
|
ConfirmDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/shared/global-env-indicator', () => ({
|
||||||
|
GlobalEnvIndicator: () => <div data-testid="global-env-indicator" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section';
|
||||||
|
import { ProfileEditor } from '@/components/profiles/editor';
|
||||||
|
|
||||||
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
|
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -24,18 +52,34 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('ImageAnalysisStatusSection', () => {
|
describe('ImageAnalysisStatusSection', () => {
|
||||||
it('renders active bridge diagnostics near the raw editor footer stack', () => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders saved diagnostics for an active backend', () => {
|
||||||
render(<ImageAnalysisStatusSection status={createStatus()} />);
|
render(<ImageAnalysisStatusSection status={createStatus()} />);
|
||||||
|
|
||||||
expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
|
expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/Derived runtime status\. This section is not written into the JSON editor above\./i
|
/Saved runtime status for this profile\. This section is derived and is not written into the JSON editor above\./i
|
||||||
)
|
)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('Ready')).toBeInTheDocument();
|
expect(screen.getByText('Configured')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Ready via Google Gemini\./i)).toBeInTheDocument();
|
expect(screen.getByText(/Configured via Google Gemini\./i)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
|
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
|
||||||
expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
|
expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument();
|
expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument();
|
||||||
@@ -57,9 +101,164 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Saved mapping')).toBeInTheDocument();
|
expect(screen.getByText('Saved mapping')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Ready via saved GitHub Copilot \(OAuth\) mapping/i)
|
screen.getByText(/Configured via saved GitHub Copilot \(OAuth\) mapping/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
|
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders hook-missing state as native file access until the hook is installed', () => {
|
||||||
|
render(
|
||||||
|
<ImageAnalysisStatusSection
|
||||||
|
status={createStatus({
|
||||||
|
status: 'hook-missing',
|
||||||
|
reason: 'Profile hook is missing from the persisted settings file.',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Setup needed')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle(/native file access \(hook install required\)/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the resolver reason for attention states like auth-token drift', () => {
|
||||||
|
render(
|
||||||
|
<ImageAnalysisStatusSection
|
||||||
|
status={createStatus({
|
||||||
|
status: 'attention',
|
||||||
|
reason:
|
||||||
|
'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.',
|
||||||
|
usesCurrentTarget: true,
|
||||||
|
usesCurrentAuthToken: false,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Needs review')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Configured via Google Gemini, but Runtime uses the current CLIProxy auth token/i
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText(/current CLIProxy auth token instead of the saved token/i)
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the panel to a live preview when the current editor JSON changes', async () => {
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.includes('/api/settings/glm/raw')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
profile: 'glm',
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'saved-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mtime: 1,
|
||||||
|
path: '/tmp/glm.settings.json',
|
||||||
|
imageAnalysisStatus: createStatus(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/settings/glm/image-analysis-status')) {
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
imageAnalysisStatus: createStatus({
|
||||||
|
backendId: 'ghcp',
|
||||||
|
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||||
|
model: 'claude-haiku-4.5',
|
||||||
|
runtimePath: '/api/provider/ghcp',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
|
target: {
|
||||||
|
value: JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'preview-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Live preview from the current editor state\. Save to persist these backend and hook changes\./i
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.includes('/api/settings/glm/raw')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
profile: 'glm',
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'saved-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mtime: 1,
|
||||||
|
path: '/tmp/glm.settings.json',
|
||||||
|
imageAnalysisStatus: createStatus(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
|
target: { value: '{' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Showing last saved runtime status\. The live preview resumes when the JSON above is valid again\./i
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user