diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
index 8afc94bc74..f08d62d870 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
@@ -2,7 +2,7 @@
// Issue #3501 strangler-fig decomposition — Phase 1t (final push)
import { useState, useEffect, useCallback, useMemo } from "react";
-import { useParams } from "next/navigation";
+import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card, Button, CardSkeleton } from "@/shared/components";
@@ -68,6 +68,7 @@ import AnonymousFallbackToggle from "./components/AnonymousFallbackToggle";
export default function ProviderDetailPageClient() {
const params = useParams();
+ const searchParams = useSearchParams();
const providerId = params.id as string;
// ── UI-only modal state (not owned by hooks) ─────────────────────────────
@@ -357,6 +358,10 @@ export default function ProviderDetailPageClient() {
setShowAddApiKeyModal(true);
}, [providerId]);
+ useEffect(() => {
+ if (searchParams.get("action") === "add-api-key") gateConnectionFlow(openApiKeyAddFlow);
+ }, [searchParams, gateConnectionFlow, openApiKeyAddFlow]);
+
const openPrimaryAddFlow = useCallback(() => {
if (providerId === "kimi-coding") return setShowKimiAuthMethodModal(true);
if (isOAuth) {
diff --git a/src/app/(dashboard)/dashboard/radar/setup/page.tsx b/src/app/(dashboard)/dashboard/radar/setup/page.tsx
index 745120d253..927b5f4eac 100644
--- a/src/app/(dashboard)/dashboard/radar/setup/page.tsx
+++ b/src/app/(dashboard)/dashboard/radar/setup/page.tsx
@@ -8,6 +8,7 @@ import { Card } from "@/shared/components";
import {
firstProviderConnectionId,
providerConnectionsRequestUrl,
+ providerSetupConnectionUrl,
type RadarSetupConnection,
} from "@/lib/radar/setupConnections";
import type { RadarLocalizedText } from "@/lib/radar/feedSchema";
@@ -132,13 +133,13 @@ export default function RadarSetupPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
- if (res.ok) {
+ const data = await res.json().catch(() => null);
+ if (res.ok && data?.valid === true) {
setTestResult({ ok: true, message: t("testSuccess") });
} else {
- const data = await res.json().catch(() => null);
setTestResult({
ok: false,
- message: data?.error?.message || t("testFailed"),
+ message: t("testFailed"),
});
}
} catch {
@@ -236,14 +237,6 @@ export default function RadarSetupPage() {
{t("noGuide")}
{t("addConnectionDescription")}
{t("addConnectionLink")} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e199e1ab30..dd74402ad9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1736,6 +1736,8 @@ "quotaShare": "Quota Share", "discovery": "Discovery", "freeProviderRankings": "Free Provider Rankings", + "radar": "Radar", + "setup": "Setup", "freeTiers": "Free Tiers", "gamification": "Gamification", "leaderboard": "Leaderboard", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index e8ec6e7f4c..290f699a4d 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1753,7 +1753,9 @@ "tools": "Ferramentas", "agentBridge": "Agent Bridge", "trafficInspector": "Inspector de Tráfego", - "usage": "Uso" + "usage": "Uso", + "radar": "Radar", + "setup": "Configuração" }, "home": { "quickStart": "Início Rápido", diff --git a/src/lib/radar/setupConnections.ts b/src/lib/radar/setupConnections.ts index 10c51f34ac..1f96ceebc9 100644 --- a/src/lib/radar/setupConnections.ts +++ b/src/lib/radar/setupConnections.ts @@ -9,6 +9,11 @@ export function providerConnectionsRequestUrl(provider: string): string { return `/api/providers?provider=${encodeURIComponent(provider)}`; } +/** Link the Radar tour to the provider's existing, validated API-key form. */ +export function providerSetupConnectionUrl(provider: string): string { + return `/dashboard/providers/${encodeURIComponent(provider)}?action=add-api-key`; +} + /** * Pick a concrete connection id for the setup test endpoint. Prefer an active * connection, then fall back to the first valid connection for the provider. diff --git a/src/shared/components/Breadcrumbs.tsx b/src/shared/components/Breadcrumbs.tsx index 0fb7cd6ce7..bee21c62ef 100644 --- a/src/shared/components/Breadcrumbs.tsx +++ b/src/shared/components/Breadcrumbs.tsx @@ -115,7 +115,8 @@ const PATH_LABELS = { */ function getLabel(segment, t) { const key = PATH_LABELS[segment]; - return key ? t(key) : segment.charAt(0).toUpperCase() + segment.slice(1); + const fallback = segment.charAt(0).toUpperCase() + segment.slice(1); + return key && typeof t.has === "function" && t.has(key) ? t(key) : fallback; } export default function Breadcrumbs() { diff --git a/tests/e2e/radar-guided-setup.spec.ts b/tests/e2e/radar-guided-setup.spec.ts new file mode 100644 index 0000000000..854ca1ef8e --- /dev/null +++ b/tests/e2e/radar-guided-setup.spec.ts @@ -0,0 +1,177 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { gotoDashboardRoute } from "./helpers/dashboardAuth"; + +const NAVIGATION_TIMEOUT_MS = 75_000; +const EVIDENCE_DIR = process.env.RADAR_G11_EVIDENCE_DIR; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function installGuidedSetupApi(page: Page) { + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + + if (url.pathname === "/api/radar/catalog") { + await fulfillJson(route, { + entries: [ + { + provider: "groq", + setup: { + keyUrl: "https://console.groq.com/keys", + steps: [ + { + en: "Create a project-specific Groq key.", + pt: "Crie uma chave Groq específica para o projeto.", + }, + ], + }, + }, + ], + }); + return; + } + + if (url.pathname === "/api/providers/expiration") { + await fulfillJson(route, { summary: { expired: 0, expiringSoon: 0 }, list: [] }); + return; + } + + if (url.pathname === "/api/provider-nodes") { + await fulfillJson(route, { nodes: [], ccCompatibleProviderEnabled: false }); + return; + } + + if (url.pathname === "/api/models/alias") { + await fulfillJson(route, method === "GET" ? { aliases: {} } : { success: true }); + return; + } + + if (url.pathname === "/api/settings/proxy") { + await fulfillJson(route, url.searchParams.has("resolve") ? { proxy: null, level: null } : {}); + return; + } + + if (url.pathname === "/api/provider-models") { + await fulfillJson(route, { models: [], modelCompatOverrides: [] }); + return; + } + + if (url.pathname === "/api/rate-limits") { + await fulfillJson(route, { providers: [] }); + return; + } + + if (url.pathname === "/api/providers/validate" && method === "POST") { + await fulfillJson(route, { valid: true }); + return; + } + + if (url.pathname === "/api/providers") { + await route.continue(); + return; + } + + if (/^\/api\/providers\/[^/]+\/sync-models$/.test(url.pathname) && method === "POST") { + await fulfillJson(route, { syncedModels: 0, models: [], availableModelsCount: 0 }); + return; + } + + if (/^\/api\/providers\/[^/]+\/test$/.test(url.pathname) && method === "POST") { + await route.continue(); + return; + } + + await route.continue(); + }); +} + +async function captureEvidence(page: Page, name: string) { + if (!EVIDENCE_DIR) return; + await page.screenshot({ path: `${EVIDENCE_DIR}/${name}.png`, fullPage: true }); +} + +test.describe("Radar guided setup", () => { + test("uses the official URL, real provider routes, isolated persistence, and real test route", async ({ + page, + }) => { + await installGuidedSetupApi(page); + + await gotoDashboardRoute(page, "/dashboard/radar/setup?provider=groq", { + timeoutMs: NAVIGATION_TIMEOUT_MS, + }); + + const officialKeyLink = page.locator('a[href="https://console.groq.com/keys"]'); + await expect(officialKeyLink).toBeVisible(); + await captureEvidence(page, "01-official-guide"); + + const addConnectionLink = page.locator( + 'a[href="/dashboard/providers/groq?action=add-api-key"]' + ); + await expect(addConnectionLink).toBeVisible(); + await Promise.all([ + page.waitForURL(/\/dashboard\/providers\/groq\?action=add-api-key$/, { + timeout: NAVIGATION_TIMEOUT_MS, + }), + addConnectionLink.click(), + ]); + const addDialog = page.getByRole("dialog"); + await expect(addDialog).toBeVisible({ timeout: NAVIGATION_TIMEOUT_MS }); + await captureEvidence(page, "02-real-api-key-form"); + await addDialog.locator('input[type="password"]').fill("test-key-not-real"); + const createResponsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/providers") && + response.request().method() === "POST" && + response.status() === 201, + { timeout: NAVIGATION_TIMEOUT_MS } + ); + await addDialog.getByRole("button", { name: /^save$/i }).click(); + const createResponse = await createResponsePromise; + const created = (await createResponse.json()) as { connection?: { id?: string } }; + const connectionId = created.connection?.id; + expect(connectionId).toBeTruthy(); + + const importDialog = page.getByRole("dialog").last(); + const closeImportButton = importDialog.getByRole("button", { name: "Close" }).last(); + await expect(closeImportButton).toBeVisible({ + timeout: NAVIGATION_TIMEOUT_MS, + }); + await closeImportButton.click(); + + await page.goto("/dashboard/radar/setup?provider=groq", { + waitUntil: "commit", + timeout: NAVIGATION_TIMEOUT_MS, + }); + await expect(page).toHaveURL(/\/dashboard\/radar\/setup\?provider=groq$/, { + timeout: NAVIGATION_TIMEOUT_MS, + }); + await expect(page.getByText(/provider is configured|provedor configurado/i)).toBeVisible({ + timeout: NAVIGATION_TIMEOUT_MS, + }); + + const testButton = page.getByRole("button", { name: /test connection|testar conexão/i }); + await expect(testButton).toBeEnabled({ timeout: NAVIGATION_TIMEOUT_MS }); + const testResponsePromise = page.waitForResponse( + (response) => + response.url().endsWith(`/api/providers/${connectionId}/test`) && + response.request().method() === "POST", + { timeout: NAVIGATION_TIMEOUT_MS } + ); + await testButton.click(); + const testResponse = await testResponsePromise; + expect(testResponse.status()).toBe(200); + const testResult = (await testResponse.json()) as { valid?: boolean }; + expect(testResult.valid).toBe(false); + await expect(page.getByText(/connection test failed|falha no teste de conexão/i)).toBeVisible({ + timeout: NAVIGATION_TIMEOUT_MS, + }); + await captureEvidence(page, "03-real-connection-test-result"); + }); +}); diff --git a/tests/unit/breadcrumbs-i18n-fallback.test.tsx b/tests/unit/breadcrumbs-i18n-fallback.test.tsx new file mode 100644 index 0000000000..b89fcb319b --- /dev/null +++ b/tests/unit/breadcrumbs-i18n-fallback.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import Breadcrumbs from "../../src/shared/components/Breadcrumbs"; +import ptBrMessages from "../../src/i18n/messages/pt-BR.json"; + +let labels: Record