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("visitDocs")} -
)} @@ -276,7 +269,7 @@ export default function RadarSetupPage() {

{t("addConnection")}

{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 = {}; +const translate = Object.assign( + vi.fn((key: string) => { + if (key in labels) return labels[key]; + throw new Error(`missing translation: ${key}`); + }), + { has: vi.fn((key: string) => key in labels) } +); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/dashboard/radar/setup", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => translate, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +describe("Breadcrumbs missing translation fallback", () => { + beforeEach(() => { + labels = { ariaLabel: "Breadcrumb", dashboard: "Dashboard" }; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + document.body.innerHTML = ""; + translate.mockClear(); + translate.has.mockClear(); + }); + + it("renders humanized Radar setup labels without asking next-intl for missing keys", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => root.render()); + + expect(container.textContent).toContain("Dashboard"); + expect(container.textContent).toContain("Radar"); + expect(container.textContent).toContain("Setup"); + expect(translate).not.toHaveBeenCalledWith("radar"); + expect(translate).not.toHaveBeenCalledWith("setup"); + act(() => root.unmount()); + }); + + it("uses localized breadcrumb labels when Radar and setup translations exist", async () => { + labels = ptBrMessages.breadcrumbs; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => root.render()); + + expect(container.textContent).toContain("Painel"); + expect(container.textContent).toContain("Radar"); + expect(container.textContent).toContain("Configuração"); + expect(translate).toHaveBeenCalledWith("radar"); + expect(translate).toHaveBeenCalledWith("setup"); + act(() => root.unmount()); + }); +}); diff --git a/tests/unit/radar-guided-setup-action.test.tsx b/tests/unit/radar-guided-setup-action.test.tsx new file mode 100644 index 0000000000..d0bb274e56 --- /dev/null +++ b/tests/unit/radar-guided-setup-action.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProviderDetailPageClient from "../../src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient"; +import RadarSetupPage from "../../src/app/(dashboard)/dashboard/radar/setup/page"; + +let providerId = "openai"; +let searchParams = new URLSearchParams("action=add-api-key"); + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: providerId }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), refresh: vi.fn() }), + usePathname: () => `/dashboard/providers/${providerId}`, + useSearchParams: () => searchParams, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +function response(body: unknown = {}) { + return { + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: { get: () => null }, + } as unknown as Response; +} + +async function renderComponent(element: React.ReactNode): Promise<{ + container: HTMLDivElement; + root: Root; +}> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(element); + await Promise.resolve(); + await Promise.resolve(); + }); + return { container, root }; +} + +async function renderProviderPage(): Promise<{ container: HTMLDivElement; root: Root }> { + return renderComponent(); +} + +async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + }); +} + +function setInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("Radar guided setup provider action", () => { + const fetchMock = vi.fn(() => Promise.resolve(response())); + + beforeEach(() => { + providerId = "openai"; + searchParams = new URLSearchParams("action=add-api-key"); + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("opens the existing API key modal for a normal provider", async () => { + const { container, root } = await renderProviderPage(); + expect(container.querySelector('input[type="password"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("keeps a subscription-risk provider behind the existing acknowledgement gate", async () => { + providerId = "chatgpt-web"; + const { container, root } = await renderProviderPage(); + + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(container.textContent).toContain("I understand, continue"); + + const confirm = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("I understand, continue") + ); + expect(confirm).toBeDefined(); + await act(async () => confirm?.click()); + expect(container.querySelector('input[type="password"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("completes get key, paste, persist, reload, and connection test with a concrete id", async () => { + providerId = "groq"; + searchParams = new URLSearchParams("provider=groq"); + let connectionExists = false; + let savedBody: Record | null = null; + let testedConnectionId: string | null = null; + + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/radar/catalog") { + return response({ + entries: [ + { + provider: "groq", + setup: { + keyUrl: "https://console.groq.com/keys", + steps: [{ en: "Create a project-specific Groq key.", pt: "Crie a chave Groq." }], + }, + }, + ], + }); + } + if (url.startsWith("/api/providers?") || url === "/api/providers") { + if (init?.method === "POST") { + savedBody = JSON.parse(String(init.body)) as Record; + connectionExists = true; + return response({ connection: { id: "conn-groq", provider: "groq", isActive: true } }); + } + return response({ + connections: connectionExists + ? [{ id: "conn-groq", provider: "groq", isActive: true }] + : [], + }); + } + if (url === "/api/providers/validate") return response({ valid: true }); + if (url === "/api/providers/conn-groq/sync-models") { + return response({ syncedModels: 0, models: [] }); + } + if (url === "/api/providers/conn-groq/test") { + testedConnectionId = "conn-groq"; + return response({ valid: true }); + } + return response(); + }); + + const firstTour = await renderComponent(); + await settle(); + expect(firstTour.container.textContent).toContain("https://console.groq.com/keys"); + expect( + firstTour.container.querySelector('a[href="/dashboard/providers/groq?action=add-api-key"]') + ).not.toBeNull(); + act(() => firstTour.root.unmount()); + + searchParams = new URLSearchParams("action=add-api-key"); + const providerPage = await renderProviderPage(); + const credential = providerPage.container.querySelector( + 'input[type="password"]' + ) as HTMLInputElement | null; + expect(credential).not.toBeNull(); + await act(async () => setInputValue(credential as HTMLInputElement, "test-key-not-real")); + const save = [...providerPage.container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Save" + ); + expect(save).toBeDefined(); + await act(async () => save?.click()); + await settle(); + expect(savedBody).toMatchObject({ provider: "groq", apiKey: "test-key-not-real" }); + expect(connectionExists).toBe(true); + act(() => providerPage.root.unmount()); + + searchParams = new URLSearchParams("provider=groq"); + const reloadedTour = await renderComponent(); + await settle(); + const testButton = [...reloadedTour.container.querySelectorAll("button")].find((button) => + button.textContent?.toLowerCase().includes("test") + ); + expect(testButton).toBeDefined(); + expect(testButton?.disabled).toBe(false); + await act(async () => testButton?.click()); + await settle(); + expect(testedConnectionId).toBe("conn-groq"); + expect(reloadedTour.container.textContent).toContain("Connection successful!"); + act(() => reloadedTour.root.unmount()); + }); + + it("reports a 200 connection-test response with valid false as a failure", async () => { + providerId = "groq"; + searchParams = new URLSearchParams("provider=groq"); + + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/radar/catalog") { + return response({ + entries: [{ provider: "groq", setup: { keyUrl: null, steps: [] } }], + }); + } + if (url.startsWith("/api/providers?")) { + return response({ + connections: [{ id: "conn-groq", provider: "groq", isActive: true }], + }); + } + if (url === "/api/providers/conn-groq/test") { + return response({ valid: false, error: "Invalid API key" }); + } + return response(); + }); + + const tour = await renderComponent(); + await settle(); + const testButton = [...tour.container.querySelectorAll("button")].find((button) => + button.textContent?.toLowerCase().includes("test") + ); + expect(testButton).toBeDefined(); + await act(async () => testButton?.click()); + await settle(); + + expect(tour.container.textContent).toContain("Connection test failed"); + expect(tour.container.textContent).not.toContain("Connection successful!"); + act(() => tour.root.unmount()); + }); +}); diff --git a/tests/unit/radar-setup-connections.test.ts b/tests/unit/radar-setup-connections.test.ts index 71771e1f98..4bc015d6df 100644 --- a/tests/unit/radar-setup-connections.test.ts +++ b/tests/unit/radar-setup-connections.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { firstProviderConnectionId, + providerSetupConnectionUrl, providerConnectionsRequestUrl, } from "../../src/lib/radar/setupConnections.ts"; @@ -26,3 +27,10 @@ test("firstProviderConnectionId selects a real connection id, never the provider ); assert.equal(firstProviderConnectionId([], "groq"), null); }); + +test("providerSetupConnectionUrl targets the real provider form with an explicit action", () => { + assert.equal( + providerSetupConnectionUrl("openrouter/custom"), + "/dashboard/providers/openrouter%2Fcustom?action=add-api-key" + ); +});