feat(radar): complete guided provider setup

This commit is contained in:
backryun
2026-08-13 04:59:12 -03:00
committed by Xiangzhe
parent baa68bfc06
commit b334d2d05a
10 changed files with 532 additions and 15 deletions

View File

@@ -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) {

View File

@@ -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() {
<Card>
<div className="text-center py-8 text-text-muted">
<p>{t("noGuide")}</p>
<a
href={`https://${provider}.com`}
target="_blank"
rel="noopener noreferrer"
className="text-violet-400 hover:underline mt-2 inline-block"
>
{t("visitDocs")}
</a>
</div>
</Card>
)}
@@ -276,7 +269,7 @@ export default function RadarSetupPage() {
<h2 className="font-semibold">{t("addConnection")}</h2>
<p className="text-sm text-text-muted">{t("addConnectionDescription")}</p>
<Link
href={`/dashboard/providers?add=${encodeURIComponent(provider)}`}
href={providerSetupConnectionUrl(provider)}
className="text-violet-400 hover:underline text-sm"
>
{t("addConnectionLink")}

View File

@@ -1736,6 +1736,8 @@
"quotaShare": "Quota Share",
"discovery": "Discovery",
"freeProviderRankings": "Free Provider Rankings",
"radar": "Radar",
"setup": "Setup",
"freeTiers": "Free Tiers",
"gamification": "Gamification",
"leaderboard": "Leaderboard",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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");
});
});

View File

@@ -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<string, string> = {};
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<HTMLAnchorElement>) => (
<a href={String(href)} {...props}>
{children}
</a>
),
}));
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(<Breadcrumbs />));
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(<Breadcrumbs />));
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());
});
});

View File

@@ -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<HTMLAnchorElement>) => (
<a href={String(href)} {...props}>
{children}
</a>
),
}));
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(<ProviderDetailPageClient />);
}
async function settle(): Promise<void> {
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<string, unknown> | 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<string, unknown>;
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(<RadarSetupPage />);
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(<RadarSetupPage />);
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(<RadarSetupPage />);
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());
});
});

View File

@@ -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"
);
});