chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let capturedTranslationsNamespace = "";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (ns: string) => {
|
||||
capturedTranslationsNamespace = ns;
|
||||
return (key: string) => key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div data-testid="card" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => (
|
||||
<button onClick={onClick} disabled={loading} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Input: ({
|
||||
label,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { label?: string }) => (
|
||||
<input aria-label={label} {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: ({ providerId }: { providerId: string; size?: number; type?: string }) => (
|
||||
<span data-testid="provider-icon" data-provider={providerId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/cli", () => ({
|
||||
CliConceptCard: ({ currentType }: { currentType: string }) => (
|
||||
<div data-testid="cli-concept-card" data-current-type={currentType} />
|
||||
),
|
||||
CliComparisonCard: ({ currentType }: { currentType: string }) => (
|
||||
<div data-testid="cli-comparison-card" data-current-type={currentType} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Fetch mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgents = [
|
||||
{
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
binary: "claude",
|
||||
version: "1.2.3",
|
||||
installed: true,
|
||||
protocol: "stdio",
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
binary: "codex",
|
||||
version: null,
|
||||
installed: false,
|
||||
protocol: "stdio",
|
||||
isCustom: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary = {
|
||||
total: 2,
|
||||
installed: 1,
|
||||
notFound: 1,
|
||||
builtIn: 2,
|
||||
custom: 0,
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ agents: mockAgents, summary: mockSummary }),
|
||||
});
|
||||
|
||||
(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = mockFetch;
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: AcpAgentsPage } = await import(
|
||||
"@/app/(dashboard)/dashboard/acp-agents/page"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderPage(): Promise<HTMLElement> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AcpAgentsPage />);
|
||||
});
|
||||
// Allow data-fetching effects to resolve
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
capturedTranslationsNamespace = "";
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AcpAgentsPage", () => {
|
||||
it("smoke: renders without crashing", async () => {
|
||||
const container = await renderPage();
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.innerHTML.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calls useTranslations with 'acpAgents' namespace", async () => {
|
||||
await renderPage();
|
||||
expect(capturedTranslationsNamespace).toBe("acpAgents");
|
||||
});
|
||||
|
||||
it("renders <CliConceptCard currentType='acp' />", async () => {
|
||||
const container = await renderPage();
|
||||
const card = container.querySelector("[data-testid='cli-concept-card']");
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.getAttribute("data-current-type")).toBe("acp");
|
||||
});
|
||||
|
||||
it("renders <CliComparisonCard currentType='acp' />", async () => {
|
||||
const container = await renderPage();
|
||||
const card = container.querySelector("[data-testid='cli-comparison-card']");
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.getAttribute("data-current-type")).toBe("acp");
|
||||
});
|
||||
|
||||
it("cross-link points to /dashboard/cli-code (not /dashboard/cli-tools)", async () => {
|
||||
const container = await renderPage();
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
const cliCodeLinks = hrefs.filter((h) => h === "/dashboard/cli-code");
|
||||
const cliToolsLinks = hrefs.filter((h) => h === "/dashboard/cli-tools");
|
||||
expect(cliCodeLinks.length).toBeGreaterThan(0);
|
||||
expect(cliToolsLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("agent grid renders with mocked /api/acp/agents response", async () => {
|
||||
const container = await renderPage();
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/acp/agents");
|
||||
// Agent names from mock should appear somewhere in the rendered output
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).toContain("Codex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// Source-level parity check (#241): AntigravityToolCard must surface
|
||||
// API-Key-compatible / custom OpenAI-compatible providers in its model picker.
|
||||
// Those provider groups in <ModelSelectModal> are derived from `modelAliases`
|
||||
// — without the prop, custom-keyed providers are silently hidden even when
|
||||
// active. The fix mirrors the pattern already used by every sibling CLI tool
|
||||
// card (Codex, Claude, Cline, Kilo, Droid, OpenClaw, HermesAgent).
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CARD_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx"
|
||||
);
|
||||
|
||||
describe("AntigravityToolCard model alias wiring", () => {
|
||||
const source = readFileSync(CARD_PATH, "utf8");
|
||||
|
||||
it("declares modelAliases state", () => {
|
||||
expect(source).toMatch(/useState\(\{\}\)/);
|
||||
expect(source).toMatch(/setModelAliases/);
|
||||
});
|
||||
|
||||
it("fetches /api/models/alias when expanded", () => {
|
||||
expect(source).toContain('fetch("/api/models/alias")');
|
||||
expect(source).toMatch(/fetchModelAliases\s*\(\s*\)/);
|
||||
});
|
||||
|
||||
it("passes modelAliases prop to ModelSelectModal", () => {
|
||||
// Regression guard for upstream parity: the prop is what unlocks the
|
||||
// API-Key-compatible / passthrough provider groups in the picker.
|
||||
expect(source).toMatch(/modelAliases=\{modelAliases\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// UI test for the opt-in Claude Code auto-permission classifier compat toggle.
|
||||
// Verifies it reads the current mode from GET /api/settings, renders "off" by
|
||||
// default, and cycles off → auto → always by PATCHing /api/settings.
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentMode = "off";
|
||||
const patchBodies: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
currentMode = "off";
|
||||
patchBodies.length = 0;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/settings")) {
|
||||
if (init?.method === "PATCH") {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
patchBodies.push(body);
|
||||
currentMode = String(body.claudeClassifierCompat);
|
||||
return new Response(JSON.stringify({ claudeClassifierCompat: currentMode }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ claudeClassifierCompat: currentMode }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const { default: ClaudeClassifierCompatToggle } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle"
|
||||
);
|
||||
|
||||
async function renderToggle() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ClaudeClassifierCompatToggle />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClaudeClassifierCompatToggle", () => {
|
||||
it("renders the current mode (off by default)", async () => {
|
||||
const container = await renderToggle();
|
||||
const btn = container.querySelector("button");
|
||||
expect(btn).toBeTruthy();
|
||||
expect((btn!.textContent ?? "").trim().toLowerCase()).toBe("off");
|
||||
});
|
||||
|
||||
it("cycles off → auto on click and PATCHes /api/settings", async () => {
|
||||
const container = await renderToggle();
|
||||
const btn = container.querySelector("button")!;
|
||||
await act(async () => {
|
||||
btn.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(patchBodies).toHaveLength(1);
|
||||
expect(patchBodies[0].claudeClassifierCompat).toBe("auto");
|
||||
expect((btn.textContent ?? "").trim().toLowerCase()).toBe("auto");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression test: when the Claude CLI is not detected locally (typical of
|
||||
// remote OmniRoute deployments where the CLI lives on the user's laptop, not
|
||||
// on the server), the card must still surface a "Manual Config" button so the
|
||||
// user can copy the settings.json snippet and paste it into the CLI on their
|
||||
// local machine. Before this fix the Manual Config button only rendered when
|
||||
// `cliReady === true`, which made the card useless for remote deployments
|
||||
// (upstream report: decolua/9router#589).
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
const messages: Record<string, string> = {
|
||||
cliNotInstalled: "{tool} CLI not detected locally",
|
||||
cliNotRunnable: "{tool} CLI installed but not runnable",
|
||||
installCliPrompt:
|
||||
"Manual configuration is still available if OmniRoute is deployed on a remote server.",
|
||||
cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.",
|
||||
howToInstall: "How to Install",
|
||||
hide: "Hide",
|
||||
manualConfig: "Manual Config",
|
||||
installationGuide: "Installation Guide",
|
||||
platforms: "Platforms",
|
||||
afterInstallationRun: "After installation run",
|
||||
toVerify: "to verify.",
|
||||
checkingCli: "Checking {tool}...",
|
||||
claudeManualConfiguration: "Claude Manual Configuration",
|
||||
};
|
||||
const raw = messages[key] ?? key;
|
||||
if (!values) return raw;
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")),
|
||||
raw
|
||||
);
|
||||
},
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
|
||||
default: () => <span data-testid="status-badge" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/services/claudeCliConfig", () => ({
|
||||
getStoredClaudeAuthValue: () => null,
|
||||
normalizeClaudeBaseUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
// Surface ManualConfigModal as a marker so we can assert it gets rendered with
|
||||
// isOpen=true after clicking the new Manual Config button.
|
||||
vi.mock("@/shared/components", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ModelSelectModal: () => null,
|
||||
ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) =>
|
||||
isOpen ? <div data-testid="manual-config-modal">{title}</div> : null,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Fetch stub ────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
// Return: CLI not installed (the broken-on-remote case from upstream #589).
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/cli-tools/claude-settings")) {
|
||||
return new Response(JSON.stringify({ installed: false }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/models/alias")) {
|
||||
return new Response(JSON.stringify({ aliases: {} }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/cli-tools/backups")) {
|
||||
return new Response(JSON.stringify({ backups: [] }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Import under test (after mocks) ───────────────────────────────────────────
|
||||
|
||||
const { default: ClaudeToolCard } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard"
|
||||
);
|
||||
|
||||
async function renderExpanded() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ClaudeToolCard
|
||||
tool={{
|
||||
name: "Claude Code",
|
||||
defaultModels: [],
|
||||
}}
|
||||
isExpanded={true}
|
||||
onToggle={() => {}}
|
||||
activeProviders={[]}
|
||||
modelMappings={{}}
|
||||
onModelMappingChange={() => {}}
|
||||
baseUrl="http://localhost:20128"
|
||||
hasActiveProviders={false}
|
||||
apiKeys={[]}
|
||||
cloudEnabled={false}
|
||||
batchStatus={null}
|
||||
lastConfiguredAt={null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// Allow microtasks for the fetch() promise + state update to flush.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClaudeToolCard — manual-config CTA when CLI is not detected", () => {
|
||||
it("renders the Manual Config button alongside How to Install when CLI is not installed", async () => {
|
||||
const container = await renderExpanded();
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const labels = buttons.map((b) => b.textContent ?? "");
|
||||
expect(labels.some((l) => l.includes("Manual Config"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("How to Install"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the ManualConfigModal when the Manual Config button is clicked", async () => {
|
||||
const container = await renderExpanded();
|
||||
const manualBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent ?? "").includes("Manual Config")
|
||||
);
|
||||
expect(manualBtn).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
manualBtn!.click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
// ── Mocks (declared before any imports that depend on them) ───────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & { src: string; alt: string }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt} {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// Stub CliStatusBadge so it doesn't depend on next-intl internals
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
|
||||
default: ({
|
||||
effectiveConfigStatus,
|
||||
}: {
|
||||
effectiveConfigStatus: string | null;
|
||||
batchStatus: null;
|
||||
lastConfiguredAt: string | null;
|
||||
}) => <span data-testid="status-badge">{effectiveConfigStatus}</span>,
|
||||
}));
|
||||
|
||||
// ── Static imports after mocks ────────────────────────────────────────────────
|
||||
|
||||
const { default: CliAgentsPageClient } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient"
|
||||
);
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 6 agent tool ids from the catalog (§3.2 of plan-14) */
|
||||
const AGENT_IDS = [
|
||||
"openclaw",
|
||||
"hermes-agent",
|
||||
"goose",
|
||||
"interpreter",
|
||||
"warp",
|
||||
"agent-deck",
|
||||
] as const;
|
||||
|
||||
function makeBatchStatusMap(overrides: Partial<ToolBatchStatusMap> = {}): ToolBatchStatusMap {
|
||||
const base: ToolBatchStatusMap = {};
|
||||
for (const id of AGENT_IDS) {
|
||||
base[id] = {
|
||||
detection: { installed: true, runnable: true, version: "1.0.0" },
|
||||
config: { status: "configured", endpoint: "http://localhost:20128", lastConfiguredAt: null },
|
||||
};
|
||||
}
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function makeFetch(data: unknown, status = 200): typeof fetch {
|
||||
return vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(String(data)),
|
||||
} as Response)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: ReturnType<typeof createRoot>[] = [];
|
||||
|
||||
async function renderPage(mockFetchFn?: typeof fetch): Promise<HTMLElement> {
|
||||
vi.stubGlobal("fetch", mockFetchFn ?? makeFetch(makeBatchStatusMap()));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CliAgentsPageClient machineId="test-machine" />);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function countAgentCards(container: HTMLElement): number {
|
||||
return Array.from(container.querySelectorAll<HTMLAnchorElement>("a[href]")).filter((a) =>
|
||||
a.getAttribute("href")?.startsWith("/dashboard/cli-agents/")
|
||||
).length;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliAgentsPageClient", () => {
|
||||
it("1. smoke render — mounts without crash and shows page title key", async () => {
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("pageTitle");
|
||||
}, 15000);
|
||||
|
||||
it("2. renders exactly 6 agent tool cards", async () => {
|
||||
const container = await renderPage();
|
||||
expect(countAgentCards(container)).toBe(6);
|
||||
}, 15000);
|
||||
|
||||
it("3. search filter — 'hermes' shows 1 card (hermes-agent)", async () => {
|
||||
const container = await renderPage();
|
||||
|
||||
const input = container.querySelector("input[type='search']") as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
// Use native value setter to trigger React's synthetic onChange
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeSetter?.call(input, "hermes");
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const visibleCards = countAgentCards(container);
|
||||
expect(visibleCards).toBe(1);
|
||||
|
||||
const remainingHrefs = Array.from(
|
||||
container.querySelectorAll<HTMLAnchorElement>("a[href]")
|
||||
)
|
||||
.filter((a) => a.getAttribute("href")?.startsWith("/dashboard/cli-agents/"))
|
||||
.map((a) => a.getAttribute("href") ?? "");
|
||||
|
||||
expect(remainingHrefs[0]).toContain("hermes");
|
||||
}, 15000);
|
||||
|
||||
it("4. detection filter 'not_installed' — shows only non-installed tools", async () => {
|
||||
// Only hermes-agent is not installed
|
||||
const map = makeBatchStatusMap({
|
||||
"hermes-agent": {
|
||||
detection: { installed: false, runnable: false },
|
||||
config: { status: "not_installed", endpoint: null, lastConfiguredAt: null },
|
||||
},
|
||||
});
|
||||
const container = await renderPage(makeFetch(map));
|
||||
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
expect(select).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeSetter?.call(select, "not_installed");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(countAgentCards(container)).toBe(1);
|
||||
const href = Array.from(container.querySelectorAll<HTMLAnchorElement>("a[href]"))
|
||||
.find((a) => a.getAttribute("href")?.startsWith("/dashboard/cli-agents/"))
|
||||
?.getAttribute("href");
|
||||
expect(href).toContain("hermes-agent");
|
||||
}, 15000);
|
||||
|
||||
it("5. empty state — shows data-testid='empty-state' when no tools match search", async () => {
|
||||
const container = await renderPage();
|
||||
|
||||
await act(async () => {
|
||||
const input = container.querySelector("input[type='search']") as HTMLInputElement;
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeSetter?.call(input, "zzznothingmatchesxyz");
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const emptyState = container.querySelector("[data-testid='empty-state']");
|
||||
expect(emptyState).not.toBeNull();
|
||||
}, 15000);
|
||||
|
||||
it("6. CliConceptCard currentType='agent' — concept.agent.title key is present", async () => {
|
||||
const container = await renderPage();
|
||||
// CliConceptCard renders "concept.agent.title" via the mock translator
|
||||
expect(container.textContent).toContain("concept.agent.title");
|
||||
}, 15000);
|
||||
|
||||
it("7. CliComparisonCard currentType='agent' — comparison.agent.title + Esta página ✓", async () => {
|
||||
const container = await renderPage();
|
||||
// CliComparisonCard renders comparison.agent.title for the current column
|
||||
expect(container.textContent).toContain("comparison.agent.title");
|
||||
// thisPage badge appears for the agent column
|
||||
expect(container.textContent).toContain("comparison.thisPage");
|
||||
expect(container.textContent).toContain("✓");
|
||||
}, 15000);
|
||||
|
||||
it("8. refresh button calls refetch — triggers additional fetch call", async () => {
|
||||
const mockFetchFn = makeFetch(makeBatchStatusMap());
|
||||
const container = await renderPage(mockFetchFn);
|
||||
|
||||
const callsAfterMount = (mockFetchFn as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(callsAfterMount).toBeGreaterThan(0);
|
||||
|
||||
const refreshBtn = container.querySelector<HTMLButtonElement>("button[aria-label]");
|
||||
expect(refreshBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
refreshBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
expect((mockFetchFn as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
|
||||
callsAfterMount
|
||||
);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EXPECTED_CODE_COUNT } from "@/shared/schemas/cliCatalog";
|
||||
import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (ns: string) => (key: string) => `${ns}.${key}`,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// Mock CLI components so tests don't pull in their heavy dependencies
|
||||
vi.mock("@/shared/components/cli", () => ({
|
||||
CliToolCard: ({
|
||||
tool,
|
||||
detailHref,
|
||||
}: {
|
||||
tool: { name: string };
|
||||
batchStatus: unknown;
|
||||
detailHref: string;
|
||||
hasActiveProviders: boolean;
|
||||
}) => (
|
||||
<div data-testid="cli-tool-card" data-href={detailHref}>
|
||||
{tool.name}
|
||||
</div>
|
||||
),
|
||||
CliConceptCard: ({ currentType }: { currentType: string }) => (
|
||||
<div data-testid="cli-concept-card" data-type={currentType} />
|
||||
),
|
||||
CliComparisonCard: ({ currentType }: { currentType: string }) => (
|
||||
<div data-testid="cli-comparison-card" data-type={currentType} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock shared components to avoid CSS/animation deps
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button data-testid="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
CardSkeleton: () => <div data-testid="card-skeleton" />,
|
||||
Input: ({ placeholder, value, onChange }: React.InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input data-testid="search-input" placeholder={placeholder} value={value} onChange={onChange} />
|
||||
),
|
||||
Toggle: ({
|
||||
checked = false,
|
||||
disabled = false,
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
onChange?: (checked: boolean) => void;
|
||||
}) => (
|
||||
<div data-testid="toggle-row">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{description ? <span>{description}</span> : null}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── useToolBatchStatuses mock ─────────────────────────────────────────────────
|
||||
|
||||
const mockRefetch = vi.fn();
|
||||
let mockStatusesReturnValue: {
|
||||
statuses: ToolBatchStatusMap | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
} = {
|
||||
statuses: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
|
||||
vi.mock("@/shared/hooks/cli/useToolBatchStatuses", () => ({
|
||||
useToolBatchStatuses: () => mockStatusesReturnValue,
|
||||
}));
|
||||
|
||||
// ── fetch mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
let mockFetchResponse: { connections?: unknown[] } = { connections: [{ isActive: true }] };
|
||||
let mockFeatureFlagsResponse = {
|
||||
flags: [
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES", effectiveValue: "false" },
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", effectiveValue: "false" },
|
||||
],
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.includes("/api/providers")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockFetchResponse),
|
||||
});
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/settings/feature-flags")) {
|
||||
if (init?.method === "PUT") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockFeatureFlagsResponse),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
|
||||
}) as typeof fetch;
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliCodePageClient } =
|
||||
await import("@/app/(dashboard)/dashboard/cli-code/CliCodePageClient");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
let roots: ReturnType<typeof createRoot>[] = [];
|
||||
|
||||
async function renderPage(props: { machineId?: string } = {}): Promise<HTMLElement> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CliCodePageClient machineId={props.machineId ?? "test-machine"} />);
|
||||
});
|
||||
|
||||
// Let any pending microtasks (fetch promises) flush
|
||||
await flushPromises();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
mockRefetch.mockReset();
|
||||
|
||||
// Reset defaults
|
||||
mockStatusesReturnValue = {
|
||||
statuses: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
mockFetchResponse = { connections: [{ isActive: true }] };
|
||||
mockFeatureFlagsResponse = {
|
||||
flags: [
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES", effectiveValue: "false" },
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", effectiveValue: "false" },
|
||||
],
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.includes("/api/providers")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockFetchResponse),
|
||||
});
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/settings/feature-flags")) {
|
||||
if (init?.method === "PUT") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockFeatureFlagsResponse),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) {
|
||||
const root = roots.pop();
|
||||
if (root) act(() => root.unmount());
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliCodePageClient", () => {
|
||||
it("1. render smoke: page renders without crash with active providers", async () => {
|
||||
const container = await renderPage();
|
||||
expect(container.innerHTML).toBeTruthy();
|
||||
// Concept + comparison cards present
|
||||
expect(container.querySelector('[data-testid="cli-concept-card"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="cli-comparison-card"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("2. renders every code tool card when catalogue is OK", async () => {
|
||||
const container = await renderPage();
|
||||
const cards = container.querySelectorAll('[data-testid="cli-tool-card"]');
|
||||
expect(cards.length).toBe(EXPECTED_CODE_COUNT);
|
||||
});
|
||||
|
||||
it("2b. renders CLI profile auto-sync toggles from feature flags", async () => {
|
||||
mockFeatureFlagsResponse = {
|
||||
flags: [
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES", effectiveValue: "true" },
|
||||
{ key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", effectiveValue: "false" },
|
||||
],
|
||||
};
|
||||
|
||||
const container = await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("CLI profile auto-sync");
|
||||
expect(container.textContent).toContain("Codex profiles");
|
||||
expect(container.textContent).toContain("Claude Code profiles");
|
||||
expect(container.textContent).not.toContain("HTTP");
|
||||
|
||||
const codexToggle = container.querySelector(
|
||||
'button[role="switch"][aria-label="Codex profiles"]'
|
||||
);
|
||||
const claudeToggle = container.querySelector(
|
||||
'button[role="switch"][aria-label="Claude Code profiles"]'
|
||||
);
|
||||
|
||||
expect(codexToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(claudeToggle?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/settings/feature-flags");
|
||||
});
|
||||
|
||||
it("2c. persists CLI profile auto-sync toggle changes", async () => {
|
||||
const container = await renderPage();
|
||||
const codexToggle = container.querySelector(
|
||||
'button[role="switch"][aria-label="Codex profiles"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(codexToggle).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
codexToggle.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/settings/feature-flags", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES",
|
||||
value: "true",
|
||||
}),
|
||||
});
|
||||
expect(codexToggle.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("3. search filter: typing 'claude' shows only 1 card", async () => {
|
||||
const container = await renderPage();
|
||||
|
||||
// All code tools initially visible
|
||||
expect(container.querySelectorAll('[data-testid="cli-tool-card"]').length).toBe(
|
||||
EXPECTED_CODE_COUNT
|
||||
);
|
||||
|
||||
const input = container.querySelector('[data-testid="search-input"]') as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
input.value = "claude";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// Simulate onChange
|
||||
const syntheticEvent = {
|
||||
target: { value: "claude" },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
// Find and call the onChange directly
|
||||
const reactProps = Object.keys(input).find((k) => k.startsWith("__reactFiber"));
|
||||
if (!reactProps) {
|
||||
// Fallback: change event
|
||||
Object.defineProperty(input, "value", { value: "claude", writable: true });
|
||||
input.dispatchEvent(
|
||||
Object.assign(new Event("change", { bubbles: true }), {
|
||||
target: input,
|
||||
})
|
||||
);
|
||||
}
|
||||
void syntheticEvent;
|
||||
});
|
||||
|
||||
// Re-render with search set via React state
|
||||
// Since we can't easily trigger React onChange from jsdom, test the filtering logic indirectly
|
||||
// by re-rendering with the search component directly
|
||||
const root2 = roots[roots.length - 1];
|
||||
await act(async () => {
|
||||
// Reset and re-render a fresh instance to test filter results
|
||||
root2.render(
|
||||
<TestWrapper search="claude">
|
||||
<CliCodePageClient machineId="test" />
|
||||
</TestWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
// We can verify with a simpler approach: check the card count remains non-zero (no crash)
|
||||
expect(container.querySelectorAll('[data-testid="cli-tool-card"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("3b. search filter with state update: typing filters cards", async () => {
|
||||
const container = await renderPage();
|
||||
const input = container.querySelector('[data-testid="search-input"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
// Simulate React change event
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, "claude code");
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid="cli-tool-card"]');
|
||||
// After filtering for "claude code", only Claude Code CLI should match
|
||||
expect(cards.length).toBeLessThan(EXPECTED_CODE_COUNT);
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
// The visible card should contain "Claude Code"
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("4. detection filter: shows skeletons when loading", async () => {
|
||||
mockStatusesReturnValue = { statuses: null, loading: true, error: null, refetch: mockRefetch };
|
||||
|
||||
const container = await renderPage();
|
||||
const skeletons = container.querySelectorAll('[data-testid="card-skeleton"]');
|
||||
expect(skeletons.length).toBe(6);
|
||||
});
|
||||
|
||||
it("5. empty state: no active providers → amber banner with link to /dashboard/providers", async () => {
|
||||
mockFetchResponse = { connections: [] };
|
||||
|
||||
const container = await renderPage();
|
||||
|
||||
// Wait for providers fetch
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The banner should appear (providers loading done, hasActiveProviders = false)
|
||||
const providerLink = container.querySelector('a[href="/dashboard/providers"]');
|
||||
expect(providerLink).not.toBeNull();
|
||||
// Banner text keys
|
||||
expect(container.textContent).toContain("detail.noActiveProviders");
|
||||
});
|
||||
|
||||
it("6. CliConceptCard rendered at top with currentType='code'", async () => {
|
||||
const container = await renderPage();
|
||||
const conceptCard = container.querySelector('[data-testid="cli-concept-card"]');
|
||||
expect(conceptCard).not.toBeNull();
|
||||
expect(conceptCard?.getAttribute("data-type")).toBe("code");
|
||||
});
|
||||
|
||||
it("7. CliComparisonCard rendered with currentType='code'", async () => {
|
||||
const container = await renderPage();
|
||||
const comparisonCard = container.querySelector('[data-testid="cli-comparison-card"]');
|
||||
expect(comparisonCard).not.toBeNull();
|
||||
expect(comparisonCard?.getAttribute("data-type")).toBe("code");
|
||||
});
|
||||
|
||||
it("8. refresh button click calls refetch()", async () => {
|
||||
const container = await renderPage();
|
||||
const refreshBtn = container.querySelector('[data-testid="button"]') as HTMLButtonElement;
|
||||
expect(refreshBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
refreshBtn.click();
|
||||
});
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("9. detailHref contains /dashboard/cli-code/<id> for each tool card", async () => {
|
||||
const container = await renderPage();
|
||||
const cards = container.querySelectorAll('[data-testid="cli-tool-card"]');
|
||||
cards.forEach((card) => {
|
||||
const href = card.getAttribute("data-href") ?? "";
|
||||
expect(href).toMatch(/^\/dashboard\/cli-code\/.+/);
|
||||
});
|
||||
});
|
||||
|
||||
it("10. skeletons shown when providersLoading is true (initial render)", async () => {
|
||||
mockStatusesReturnValue = { statuses: null, loading: true, error: null, refetch: mockRefetch };
|
||||
|
||||
// Delay the fetch so providers loading is true on initial render
|
||||
const slowFetch = vi.fn().mockImplementation(() => new Promise(() => {})) as typeof fetch;
|
||||
globalThis.fetch = slowFetch;
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
// Render without awaiting fetch resolution
|
||||
act(() => {
|
||||
root.render(<CliCodePageClient machineId="test" />);
|
||||
});
|
||||
|
||||
const skeletons = container.querySelectorAll('[data-testid="card-skeleton"]');
|
||||
expect(skeletons.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper wrapper (not exported) — needed only for test 3 internal use
|
||||
function TestWrapper({ children }: { children: React.ReactNode; search?: string }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliConceptType } from "@/shared/components/cli/CliConceptCard";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => {
|
||||
const messages: Record<string, string> = {
|
||||
"comparison.thisPage": "This page",
|
||||
"comparison.open": "Open →",
|
||||
"comparison.code.title": "Code tool",
|
||||
"comparison.agent.title": "Broad autonomous agent",
|
||||
"comparison.acp.title": "CLI used as backend by Omni",
|
||||
};
|
||||
return (key: string) => messages[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliComparisonCard } = await import("@/shared/components/cli/CliComparisonCard");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(currentType: CliConceptType): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<CliComparisonCard currentType={currentType} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliComparisonCard", () => {
|
||||
it("renders 3 columns for all types", () => {
|
||||
const container = renderCard("code");
|
||||
expect(container.textContent).toContain("Code tool");
|
||||
expect(container.textContent).toContain("Broad autonomous agent");
|
||||
expect(container.textContent).toContain("CLI used as backend by Omni");
|
||||
});
|
||||
|
||||
it("shows current page badge for currentType=code column", () => {
|
||||
const container = renderCard("code");
|
||||
expect(container.textContent).toContain("This page");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("shows current page badge for currentType=agent column", () => {
|
||||
const container = renderCard("agent");
|
||||
expect(container.textContent).toContain("This page");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("shows current page badge for currentType=acp column", () => {
|
||||
const container = renderCard("acp");
|
||||
expect(container.textContent).toContain("This page");
|
||||
expect(container.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("renders Open → links for the non-current columns", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const texts = Array.from(links).map((a) => a.textContent);
|
||||
const verLinks = texts.filter((t) => t?.includes("Open →"));
|
||||
// 2 non-current columns → 2 links
|
||||
expect(verLinks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("for currentType=code, Ver → links point to agent and acp hrefs", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
});
|
||||
|
||||
it("current column has primary styling class", () => {
|
||||
const container = renderCard("code");
|
||||
// The current column div has bg-primary/10 class
|
||||
const currentCol = container.querySelector('[class*="primary"]');
|
||||
expect(currentCol).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliConceptType } from "@/shared/components/cli/CliConceptCard";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliConceptCard } = await import("@/shared/components/cli/CliConceptCard");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(currentType: CliConceptType): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<CliConceptCard currentType={currentType} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliConceptCard", () => {
|
||||
it("renders with currentType=code", () => {
|
||||
const container = renderCard("code");
|
||||
// The card renders (concept keys are shown as raw key strings from mock)
|
||||
expect(container.textContent).toContain("concept.code.title");
|
||||
});
|
||||
|
||||
it("renders with currentType=agent", () => {
|
||||
const container = renderCard("agent");
|
||||
expect(container.textContent).toContain("concept.agent.title");
|
||||
});
|
||||
|
||||
it("renders with currentType=acp", () => {
|
||||
const container = renderCard("acp");
|
||||
expect(container.textContent).toContain("concept.acp.title");
|
||||
});
|
||||
|
||||
it("for currentType=code, card has primary bg class", () => {
|
||||
const container = renderCard("code");
|
||||
// The root card div should have primary/5 styling
|
||||
const card = container.firstElementChild as HTMLElement;
|
||||
expect(card?.className ?? "").toContain("primary");
|
||||
});
|
||||
|
||||
it("for currentType=code, renders chips for agent and acp (not code)", () => {
|
||||
const container = renderCard("code");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
// Should NOT link to itself
|
||||
expect(hrefs).not.toContain("/dashboard/cli-code");
|
||||
});
|
||||
|
||||
it("for currentType=agent, renders chips for code and acp", () => {
|
||||
const container = renderCard("agent");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-code");
|
||||
expect(hrefs).toContain("/dashboard/acp-agents");
|
||||
expect(hrefs).not.toContain("/dashboard/cli-agents");
|
||||
});
|
||||
|
||||
it("for currentType=acp, renders chips for code and agent", () => {
|
||||
const container = renderCard("acp");
|
||||
const links = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(links).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/cli-code");
|
||||
expect(hrefs).toContain("/dashboard/cli-agents");
|
||||
expect(hrefs).not.toContain("/dashboard/acp-agents");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => {
|
||||
const messages: Record<string, string> = {
|
||||
"card.detected": "Detected",
|
||||
"card.notDetected": "Not detected",
|
||||
"card.configure": "Configure →",
|
||||
"card.howToInstall": "How to install →",
|
||||
"card.baseUrlPartial": "Partial Base URL",
|
||||
"card.alsoAcp": "also ACP",
|
||||
"card.connectProviderHint": "Connect a provider in Providers",
|
||||
};
|
||||
return (key: string) => messages[key] ?? key;
|
||||
},
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// Stub CliStatusBadge so it doesn't need next-intl internals
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
|
||||
default: ({
|
||||
effectiveConfigStatus,
|
||||
}: {
|
||||
effectiveConfigStatus: string | null;
|
||||
batchStatus: null;
|
||||
lastConfiguredAt: string | null;
|
||||
}) => <span data-testid="status-badge">{effectiveConfigStatus}</span>,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliToolCard } = await import("@/shared/components/cli/CliToolCard");
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTool(overrides: Partial<CliCatalogEntry> = {}): CliCatalogEntry {
|
||||
return {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
icon: "terminal",
|
||||
color: "#D97757",
|
||||
description: "Anthropic Claude Code CLI",
|
||||
docsUrl: "https://example.com",
|
||||
configType: "env",
|
||||
category: "code",
|
||||
vendor: "Anthropic",
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "full",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBatchStatus(overrides: Partial<ToolBatchStatus> = {}): ToolBatchStatus {
|
||||
return {
|
||||
detection: { installed: true, runnable: true, version: "1.2.3" },
|
||||
config: { status: "configured", endpoint: "http://localhost:20128", lastConfiguredAt: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard(
|
||||
tool: CliCatalogEntry,
|
||||
batchStatus: ToolBatchStatus | null,
|
||||
detailHref: string,
|
||||
hasActiveProviders: boolean
|
||||
): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<CliToolCard
|
||||
tool={tool}
|
||||
batchStatus={batchStatus}
|
||||
detailHref={detailHref}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliToolCard", () => {
|
||||
it("renders tool name", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/dashboard/cli-code/claude", true);
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("links to detailHref", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/dashboard/cli-code/claude", true);
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link!.getAttribute("href")).toBe("/dashboard/cli-code/claude");
|
||||
});
|
||||
|
||||
it("shows version when installed", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("1.2.3");
|
||||
});
|
||||
|
||||
it("shows 'not found' when not installed", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false, version: undefined },
|
||||
});
|
||||
const container = renderCard(makeTool(), status, "/detail", true);
|
||||
expect(container.textContent).toContain("not found");
|
||||
});
|
||||
|
||||
it("shows configure footer when installed", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("Configure →");
|
||||
});
|
||||
|
||||
it("shows install footer when not installed", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const container = renderCard(makeTool(), status, "/detail", true);
|
||||
expect(container.textContent).toContain("How to install →");
|
||||
});
|
||||
|
||||
it("shows partial baseUrl amber badge", () => {
|
||||
const tool = makeTool({ baseUrlSupport: "partial" });
|
||||
const container = renderCard(tool, makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("Partial Base URL");
|
||||
});
|
||||
|
||||
it("shows also ACP badge when acpSpawnable is true", () => {
|
||||
const tool = makeTool({ acpSpawnable: true });
|
||||
const container = renderCard(tool, makeBatchStatus(), "/detail", true);
|
||||
expect(container.textContent).toContain("also ACP");
|
||||
});
|
||||
|
||||
it("shows provider tooltip text when hasActiveProviders is false", () => {
|
||||
const container = renderCard(makeTool(), makeBatchStatus(), "/detail", false);
|
||||
expect(container.textContent).toContain("Connect a provider in Providers");
|
||||
});
|
||||
|
||||
it("shows install chips when not installed and configType is not guide", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const tool = makeTool({ configType: "custom" });
|
||||
const container = renderCard(tool, status, "/detail", true);
|
||||
expect(container.textContent).toContain("Manual config");
|
||||
expect(container.textContent).toContain("Install");
|
||||
});
|
||||
|
||||
it("does NOT show install chips when configType is guide", () => {
|
||||
const status = makeBatchStatus({
|
||||
detection: { installed: false, runnable: false },
|
||||
});
|
||||
const tool = makeTool({ configType: "guide" });
|
||||
const container = renderCard(tool, status, "/detail", true);
|
||||
expect(container.textContent).not.toContain("Manual config");
|
||||
});
|
||||
|
||||
it("renders gracefully with null batchStatus", () => {
|
||||
const container = renderCard(makeTool(), null, "/detail", true);
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).toContain("not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Guards fix for issue #6039:
|
||||
* CompressionHub was sending the full merged settings object to PUT
|
||||
* /api/settings/compression instead of only the patch. The API schema uses
|
||||
* .strict() Zod validation, so any field present in CompressionConfig but
|
||||
* absent from compressionSettingsUpdateSchema (e.g. contextBudget, or
|
||||
* stackedPipeline steps using engines not yet in the discriminated union)
|
||||
* would cause a 400 validation failure — making switching to any non-default
|
||||
* combo fail silently.
|
||||
*
|
||||
* Fix: send `patch` (the caller-supplied partial update) instead of `next`
|
||||
* (the full optimistically-merged state).
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub next/navigation (not used by CompressionHub but required by module graph)
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
// Track all fetch calls
|
||||
const fetchCalls: { url: string; init: RequestInit }[] = [];
|
||||
const mockFetch = vi.fn().mockImplementation((url: string, init: RequestInit) => {
|
||||
fetchCalls.push({ url, init });
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ combos: [] }),
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Import after mocks are in place
|
||||
import CompressionHub from "../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getLastPutBody(): Record<string, unknown> | null {
|
||||
const putCall = [...fetchCalls].reverse().find(
|
||||
(c) => c.init?.method === "PUT"
|
||||
);
|
||||
if (!putCall) return null;
|
||||
return JSON.parse(putCall.init.body as string);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionHub — PUT sends patch only, not full settings", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls.length = 0;
|
||||
mockFetch.mockClear();
|
||||
|
||||
// GET /api/settings/compression returns a config with extra fields that
|
||||
// are NOT in compressionSettingsUpdateSchema (simulates contextBudget etc.)
|
||||
mockFetch.mockImplementationOnce((_url: string) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
defaultMode: "lite",
|
||||
activeComboId: null,
|
||||
contextEditing: { enabled: false },
|
||||
// Field present in CompressionConfig but NOT in the update schema:
|
||||
contextBudget: { mode: "floor", floorTokens: 4096 },
|
||||
// Engine step type not in stackedPipelineStepSchema discriminated union:
|
||||
stackedPipeline: [
|
||||
{ engine: "headroom", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "lite" },
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/settings/compression/combos
|
||||
mockFetch.mockImplementationOnce((_url: string) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ combos: [{ id: "c1", name: "My Combo", pipeline: [] }] }),
|
||||
})
|
||||
);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
it("sends only the changed field when activeComboId is updated", async () => {
|
||||
await act(async () => {
|
||||
root.render(<CompressionHub />);
|
||||
});
|
||||
|
||||
// Find the combo selector and change it to "c1"
|
||||
const select = container.querySelector("select");
|
||||
expect(select).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
Object.defineProperty(select, "value", { writable: true, value: "c1" });
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const body = getLastPutBody();
|
||||
expect(body).not.toBeNull();
|
||||
|
||||
// Must contain the changed field
|
||||
expect(body).toHaveProperty("activeComboId", "c1");
|
||||
|
||||
// Must NOT contain fields from the full settings that would fail strict
|
||||
// schema validation (contextBudget, stackedPipeline with unknown engines)
|
||||
expect(body).not.toHaveProperty("contextBudget");
|
||||
expect(body).not.toHaveProperty("stackedPipeline");
|
||||
expect(body).not.toHaveProperty("enabled");
|
||||
expect(body).not.toHaveProperty("defaultMode");
|
||||
});
|
||||
|
||||
it("sends only the toggle field when contextEditing is toggled", async () => {
|
||||
await act(async () => {
|
||||
root.render(<CompressionHub />);
|
||||
});
|
||||
|
||||
// Find the context editing toggle button
|
||||
const toggleButtons = container.querySelectorAll("button[role='switch']");
|
||||
expect(toggleButtons.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
(toggleButtons[0] as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
const body = getLastPutBody();
|
||||
expect(body).not.toBeNull();
|
||||
|
||||
// Should only include contextEditing, not the full settings
|
||||
expect(body).toHaveProperty("contextEditing");
|
||||
expect(body).not.toHaveProperty("contextBudget");
|
||||
expect(body).not.toHaveProperty("stackedPipeline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression guard for the base-red introduced by #6061: CoolingConnectionsPanel
|
||||
// imported `Card` from a non-existent `@/components/ui/card`, which passed the
|
||||
// PR→release fast-gates (they don't run `next build`) but broke `next build`
|
||||
// with `Module not found: Can't resolve '@/components/ui/card'`. Importing the
|
||||
// component here fails at module-load if that broken import ever comes back,
|
||||
// so this test fails-without-the-fix.
|
||||
|
||||
// `formatResetCountdown` lives in the client-safe `@/shared/utils/formatting`
|
||||
// module (imported directly by the panel — never via the server-only localDb
|
||||
// barrel, which would drag ioredis/node:net into the browser bundle). Stub it so
|
||||
// the countdown text is deterministic.
|
||||
vi.mock("@/shared/utils/formatting", () => ({
|
||||
formatResetCountdown: (v: string | number | null | undefined) => (v == null ? null : "in 5m"),
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
const PANEL_PATH = "@/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel";
|
||||
|
||||
describe("CoolingConnectionsPanel", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("module loads and exports a default component (guards the import path)", async () => {
|
||||
const mod = await import(PANEL_PATH);
|
||||
expect(typeof mod.default).toBe("function");
|
||||
});
|
||||
|
||||
it("renders the panel with a countdown for a cooling connection", async () => {
|
||||
const { default: CoolingConnectionsPanel } = await import(PANEL_PATH);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const future = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CoolingConnectionsPanel, {
|
||||
connections: [{ id: "conn-abc12345", displayName: "My Key", rateLimitedUntil: future }],
|
||||
})
|
||||
);
|
||||
});
|
||||
const panel = container.querySelector("[data-testid='cooling-connections-panel']");
|
||||
expect(panel).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='cooling-countdown']")?.textContent).toContain(
|
||||
"in 5m"
|
||||
);
|
||||
expect(panel?.textContent).toContain("My Key");
|
||||
});
|
||||
|
||||
it("renders nothing when no connection is cooling", async () => {
|
||||
const { default: CoolingConnectionsPanel } = await import(PANEL_PATH);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CoolingConnectionsPanel, {
|
||||
connections: [{ id: "conn-old", displayName: "Expired", rateLimitedUntil: past }],
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='cooling-connections-panel']")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression guard (release v3.8.44 e2e finding): LocaleAutoDetect called
|
||||
// router.refresh() on EVERY first visit — even when the detected browser
|
||||
// locale was exactly the one the server had just rendered — re-navigating the
|
||||
// page mid-interaction (Playwright: "Execution context was destroyed") and
|
||||
// flashing for every new visitor. It must refresh ONLY when the detected
|
||||
// locale differs from <html lang>.
|
||||
|
||||
const refresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh }),
|
||||
}));
|
||||
|
||||
describe("LocaleAutoDetect refresh gating", () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
refresh.mockClear();
|
||||
// Fresh visit: no locale cookie.
|
||||
document.cookie = "NEXT_LOCALE=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
Object.defineProperty(navigator, "languages", { value: ["en-US"], configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function mount() {
|
||||
const { LocaleAutoDetect } = await import("@/shared/components/LocaleAutoDetect");
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleAutoDetect));
|
||||
});
|
||||
cleanups.push(() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
});
|
||||
}
|
||||
|
||||
it("does NOT refresh when the detected locale equals the server-rendered <html lang>", async () => {
|
||||
document.documentElement.lang = "en";
|
||||
await mount();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes when the detected locale differs from the server-rendered <html lang>", async () => {
|
||||
document.documentElement.lang = "fr";
|
||||
await mount();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression test: when the Open Claw CLI is not detected locally (typical of
|
||||
// remote OmniRoute deployments where the CLI lives on the user's laptop, not
|
||||
// on the server), the card must still surface a "Manual Config" button so the
|
||||
// user can copy the settings.json snippet and paste it into the CLI on their
|
||||
// local machine. Before this fix the Manual Config button only rendered when
|
||||
// `cliReady === true`, which made the card useless for remote deployments
|
||||
// (upstream report: decolua/9router#579, port of decolua/9router#615).
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
const messages: Record<string, string> = {
|
||||
cliNotInstalled: "{tool} CLI not detected locally",
|
||||
cliNotRunnable: "{tool} CLI installed but not runnable",
|
||||
installCliPrompt:
|
||||
"Manual configuration is still available if OmniRoute is deployed on a remote server.",
|
||||
cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.",
|
||||
manualConfig: "Manual Config",
|
||||
checkingCli: "Checking {tool}...",
|
||||
openClawManualConfiguration: "Open Claw Manual Configuration",
|
||||
"toolDescriptions.openclaw": "Open Claw CLI",
|
||||
};
|
||||
const raw = messages[key] ?? key;
|
||||
if (!values) return raw;
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")),
|
||||
raw
|
||||
);
|
||||
},
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: () => <span data-testid="next-image" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
|
||||
default: () => <span data-testid="status-badge" />,
|
||||
}));
|
||||
|
||||
// Surface ManualConfigModal as a marker so we can assert it gets rendered with
|
||||
// isOpen=true after clicking the new Manual Config button.
|
||||
vi.mock("@/shared/components", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ModelSelectModal: () => null,
|
||||
ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) =>
|
||||
isOpen ? <div data-testid="manual-config-modal">{title}</div> : null,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Fetch stub ────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
// Return: CLI not installed (the broken-on-remote case from upstream #579).
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/cli-tools/openclaw-settings")) {
|
||||
return new Response(JSON.stringify({ installed: false }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/models/alias")) {
|
||||
return new Response(JSON.stringify({ aliases: {} }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/cli-tools/backups")) {
|
||||
return new Response(JSON.stringify({ backups: [] }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Import under test (after mocks) ───────────────────────────────────────────
|
||||
|
||||
const { default: OpenClawToolCard } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard"
|
||||
);
|
||||
|
||||
async function renderExpanded() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<OpenClawToolCard
|
||||
tool={{ name: "Open Claw", defaultModels: [] }}
|
||||
isExpanded={true}
|
||||
onToggle={() => {}}
|
||||
activeProviders={[]}
|
||||
baseUrl="http://localhost:20128"
|
||||
hasActiveProviders={false}
|
||||
apiKeys={[]}
|
||||
cloudEnabled={false}
|
||||
batchStatus={null}
|
||||
lastConfiguredAt={null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// Allow microtasks for the fetch() promise + state update to flush.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("OpenClawToolCard — manual-config CTA when CLI is not detected", () => {
|
||||
it("renders the Manual Config button when CLI is not installed", async () => {
|
||||
const container = await renderExpanded();
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const labels = buttons.map((b) => b.textContent ?? "");
|
||||
expect(labels.some((l) => l.includes("Manual Config"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the ManualConfigModal when the Manual Config button is clicked", async () => {
|
||||
const container = await renderExpanded();
|
||||
const manualBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent ?? "").includes("Manual Config")
|
||||
);
|
||||
expect(manualBtn).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
manualBtn!.click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
// #2166 — ProviderIcon custom remote icon URL (`src` prop) support.
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
const { onError, alt, ...rest } = props as { onError?: () => void; alt?: string } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// eslint-disable-next-line @next/next/no-img-element -- test double for next/image
|
||||
return <img data-testid="next-image" alt={alt || ""} onError={onError} {...rest} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: ProviderIcon } = await import("@/shared/components/ProviderIcon");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Deliberately not registered in @lobehub/icons aliases or the KNOWN_PNGS/KNOWN_SVGS
|
||||
// static-asset sets, so tests exercise only the `src` override + generic-icon fallback
|
||||
// paths, never the @lobehub/static resolution chain.
|
||||
const UNKNOWN_PROVIDER_ID = "openai-compatible-test-node-xyz";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderIcon(props: Record<string, unknown>): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ProviderIcon providerId={UNKNOWN_PROVIDER_ID} {...props} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
function fireImgError(container: HTMLElement) {
|
||||
const img = container.querySelector("img");
|
||||
if (!img) throw new Error("expected an <img> element to fire error on");
|
||||
act(() => {
|
||||
img.dispatchEvent(new Event("error"));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ProviderIcon — custom remote icon URL (#2166)", () => {
|
||||
it("renders an <img> with the given src when `src` is set", () => {
|
||||
const container = renderIcon({ src: "https://example.com/logo.png", size: 32 });
|
||||
const img = container.querySelector("img");
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute("src")).toBe("https://example.com/logo.png");
|
||||
});
|
||||
|
||||
it("falls back to the generic icon when `src` is unset", () => {
|
||||
const container = renderIcon({});
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the generic (lobehub/static) icon chain when `src` load fails and no fallbackText is given", () => {
|
||||
const container = renderIcon({ src: "https://example.com/broken.png" });
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
|
||||
fireImgError(container);
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a text badge when `src` load fails and fallbackText is given", () => {
|
||||
const container = renderIcon({
|
||||
src: "https://example.com/broken.png",
|
||||
fallbackText: "OC",
|
||||
fallbackColor: "#10A37F",
|
||||
});
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
|
||||
fireImgError(container);
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
expect(container.textContent).toBe("OC");
|
||||
});
|
||||
|
||||
it("ignores a whitespace-only src and falls back to the generic icon", () => {
|
||||
const container = renderIcon({ src: " " });
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression guard for the #5918 TDZ crash: ProxyRegistryManager called
|
||||
// `useProxyBatchOperations(load)` BEFORE the `const load = useCallback(...)`
|
||||
// declaration in the component body, so every SERVER render threw
|
||||
// `ReferenceError: Cannot access 'load' before initialization` — the whole
|
||||
// /dashboard/system/proxy page 500'd in production (digest 539380095), caught
|
||||
// only by the release-PR e2e smoke (the PR→release fast-gates render nothing).
|
||||
// renderToString mirrors that SSR path exactly (no effects, no fetches) and is
|
||||
// synchronous — this test fails-without-the-fix at the first render.
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("ProxyRegistryManager (TDZ regression #5918)", () => {
|
||||
it("server-renders without a use-before-init ReferenceError", { timeout: 30000 }, async () => {
|
||||
const { default: ProxyRegistryManager } =
|
||||
await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager");
|
||||
const html = renderToString(React.createElement(ProxyRegistryManager));
|
||||
// The heading key is rendered via the mocked translator (key echo).
|
||||
expect(html).toContain("title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// Stub fetch globally
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Stub next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub CLI_TOOLS catalog
|
||||
vi.mock("@/shared/constants/cliTools", () => ({
|
||||
CLI_TOOLS: {
|
||||
claude: {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
icon: "terminal",
|
||||
color: "#D97757",
|
||||
category: "code",
|
||||
configType: "env",
|
||||
vendor: "Anthropic",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
icon: "terminal",
|
||||
color: "#000",
|
||||
category: "code",
|
||||
configType: "custom",
|
||||
vendor: "OpenAI",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
custom: {
|
||||
id: "custom",
|
||||
name: "Custom CLI",
|
||||
icon: "terminal",
|
||||
color: "#888",
|
||||
category: "code",
|
||||
configType: "custom-builder",
|
||||
vendor: undefined,
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
"hermes-agent": {
|
||||
id: "hermes-agent",
|
||||
name: "Hermes Agent",
|
||||
icon: "terminal",
|
||||
color: "#5865f2",
|
||||
category: "agent",
|
||||
configType: "custom",
|
||||
vendor: "HermesAI",
|
||||
baseUrlSupport: "full",
|
||||
defaultModels: [],
|
||||
},
|
||||
forge: {
|
||||
id: "forge",
|
||||
name: "Forge",
|
||||
icon: "terminal",
|
||||
color: "#888",
|
||||
category: "code",
|
||||
configType: "custom",
|
||||
vendor: undefined,
|
||||
baseUrlSupport: "partial",
|
||||
defaultModels: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub model constants
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub specialized cards — render a testid so we can identify which was rendered
|
||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
|
||||
ClaudeToolCard: () => <div data-testid="ClaudeToolCard" />,
|
||||
CodexToolCard: () => <div data-testid="CodexToolCard" />,
|
||||
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
||||
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
||||
ClineToolCard: () => <div data-testid="ClineToolCard" />,
|
||||
KiloToolCard: () => <div data-testid="KiloToolCard" />,
|
||||
DefaultToolCard: ({ toolId }: { toolId: string }) => (
|
||||
<div data-testid="DefaultToolCard" data-toolid={toolId} />
|
||||
),
|
||||
AntigravityToolCard: () => <div data-testid="AntigravityToolCard" />,
|
||||
CopilotToolCard: () => <div data-testid="CopilotToolCard" />,
|
||||
CustomCliCard: () => <div data-testid="CustomCliCard" />,
|
||||
HermesAgentToolCard: () => <div data-testid="HermesAgentToolCard" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({
|
||||
default: () => <div data-testid="CliproxyapiToolCard" />,
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: ToolDetailClient } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderDetail(toolId: string, category: "code" | "agent"): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ToolDetailClient toolId={toolId} category={category} />);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ToolDetailClient", () => {
|
||||
it("renders ClaudeToolCard for toolId=claude", async () => {
|
||||
const container = renderDetail("claude", "code");
|
||||
// Wait for async state resolution
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='ClaudeToolCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders CodexToolCard for toolId=codex", async () => {
|
||||
const container = renderDetail("codex", "code");
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='CodexToolCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders CustomCliCard for toolId=custom", async () => {
|
||||
const container = renderDetail("custom", "code");
|
||||
await act(async () => {});
|
||||
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
|
||||
const container = renderDetail("forge", "code");
|
||||
await act(async () => {});
|
||||
const card = container.querySelector("[data-testid='DefaultToolCard']");
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.getAttribute("data-toolid")).toBe("forge");
|
||||
});
|
||||
|
||||
it("renders nothing (null) for completely unknown toolId", async () => {
|
||||
const container = renderDetail("totally-unknown-xyz", "code");
|
||||
await act(async () => {});
|
||||
// CLI_TOOLS["totally-unknown-xyz"] is undefined → returns null → empty container
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Verifies that the logs/activity page calls permanentRedirect("/dashboard/activity").
|
||||
* We mock next/navigation so no Next.js runtime is needed.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Mock next/navigation before importing the page
|
||||
let capturedRedirectTarget: string | undefined;
|
||||
|
||||
// Stub permanentRedirect to capture the call instead of throwing
|
||||
const mockNavigation = {
|
||||
permanentRedirect: (target: string) => {
|
||||
capturedRedirectTarget = target;
|
||||
// permanentRedirect normally throws (NEXT_REDIRECT error)
|
||||
// In tests we just record the call
|
||||
},
|
||||
redirect: () => {},
|
||||
useRouter: () => ({}),
|
||||
usePathname: () => "",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
};
|
||||
|
||||
// Node.js module mock via loader is complex; instead we test by importing
|
||||
// the source and verifying the permanent redirect is invoked correctly
|
||||
// by inspecting the module's source text.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const PAGE_PATH = resolve(
|
||||
import.meta.dirname ?? new URL(".", import.meta.url).pathname,
|
||||
"../../../src/app/(dashboard)/dashboard/logs/activity/page.tsx"
|
||||
);
|
||||
|
||||
test("logs/activity/page.tsx contains permanentRedirect('/dashboard/activity')", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf-8");
|
||||
assert.ok(
|
||||
src.includes("permanentRedirect"),
|
||||
"page.tsx must call permanentRedirect"
|
||||
);
|
||||
assert.ok(
|
||||
src.includes("/dashboard/activity"),
|
||||
"page.tsx must redirect to /dashboard/activity"
|
||||
);
|
||||
assert.ok(
|
||||
src.includes(`from "next/navigation"`),
|
||||
"page.tsx must import from next/navigation"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs/activity/page.tsx does NOT import AuditLogTab anymore", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf-8");
|
||||
assert.ok(
|
||||
!src.includes("AuditLogTab"),
|
||||
"page.tsx must not reference AuditLogTab after F4 cleanup"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs/activity/page.tsx does NOT have 'use client' directive (server component)", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf-8");
|
||||
assert.ok(
|
||||
!src.includes('"use client"'),
|
||||
"redirect page must be a server component (no 'use client')"
|
||||
);
|
||||
});
|
||||
|
||||
test("AuditLogTab.tsx no longer exists (deleted by F4)", () => {
|
||||
const AUDIT_LOG_TAB_PATH = resolve(
|
||||
import.meta.dirname ?? new URL(".", import.meta.url).pathname,
|
||||
"../../../src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx"
|
||||
);
|
||||
let exists = false;
|
||||
try {
|
||||
readFileSync(AUDIT_LOG_TAB_PATH);
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
assert.ok(!exists, "AuditLogTab.tsx must have been deleted");
|
||||
});
|
||||
|
||||
// Also confirm ActivityFeedClient exists
|
||||
test("activity/ActivityFeedClient.tsx exists", () => {
|
||||
const CLIENT_PATH = resolve(
|
||||
import.meta.dirname ?? new URL(".", import.meta.url).pathname,
|
||||
"../../../src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx"
|
||||
);
|
||||
let src: string;
|
||||
try {
|
||||
src = readFileSync(CLIENT_PATH, "utf-8");
|
||||
} catch {
|
||||
assert.fail("ActivityFeedClient.tsx does not exist");
|
||||
return;
|
||||
}
|
||||
assert.ok(src.includes("/api/compliance/audit-log"), "Client must fetch from audit-log endpoint");
|
||||
// The client sets level: "high" in URLSearchParams (produces level=high in the query string)
|
||||
assert.ok(
|
||||
src.includes('level: "high"') || src.includes("level=high"),
|
||||
"Client must request level=high"
|
||||
);
|
||||
});
|
||||
|
||||
// Dummy to satisfy the mockNavigation reference (avoid unused var lint)
|
||||
void mockNavigation;
|
||||
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// next-intl: return the key so we can assert on stable strings.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddApiKeyModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal");
|
||||
|
||||
const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]';
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddApiKeyModal
|
||||
isOpen
|
||||
onSave={async () => undefined}
|
||||
onClose={() => {}}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — import only free models", () => {
|
||||
it("shows the free-only toggle for a provider that has free models", () => {
|
||||
const el = render({ provider: "openrouter", providerName: "OpenRouter" });
|
||||
expect(el.querySelector(FREE_TOGGLE)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the free-only toggle for a provider without free models", () => {
|
||||
const el = render({ provider: "anthropic", providerName: "Anthropic" });
|
||||
expect(el.querySelector(FREE_TOGGLE)).toBeNull();
|
||||
});
|
||||
|
||||
it("includes importFreeModelsOnly in the saved payload when toggled on", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "openrouter", providerName: "OpenRouter", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
setInputValue(nameInput, "My OpenRouter");
|
||||
setInputValue(apiKeyInput, "sk-or-test-key");
|
||||
|
||||
const toggle = el.querySelector<HTMLButtonElement>(FREE_TOGGLE)!;
|
||||
act(() => {
|
||||
toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.importFreeModelsOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — quota scraping fields", () => {
|
||||
it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const workspaceInput = el.querySelector<HTMLInputElement>(
|
||||
'input[name="opencodeGoWorkspaceId"]'
|
||||
)!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="opencodeGoAuthCookie"]')!;
|
||||
setInputValue(nameInput, "OpenCode Go");
|
||||
setInputValue(apiKeyInput, "sk-opencode-go-test");
|
||||
setInputValue(workspaceInput, "workspace-123");
|
||||
setInputValue(cookieInput, "auth=opencode-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123");
|
||||
expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie");
|
||||
});
|
||||
|
||||
it("saves Ollama Cloud usage cookie in providerSpecificData", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "ollama-cloud", providerName: "Ollama Cloud", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="ollamaCloudUsageCookie"]')!;
|
||||
setInputValue(nameInput, "Ollama Cloud");
|
||||
setInputValue(apiKeyInput, "ollama-key");
|
||||
setInputValue(cookieInput, "__Secure-session=ollama-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.ollamaCloudUsageCookie).toBe(
|
||||
"__Secure-session=ollama-cookie"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// #5565 / #5567 — Providers without a live validator (e.g. lmarena session
|
||||
// cookie, piapi API key) return `{ unsupported: true }` from
|
||||
// /api/providers/validate. The modal previously treated that like a hard
|
||||
// "Invalid" and blocked Save entirely, so the credential could never be added.
|
||||
// "Validation not supported" must be a non-blocking warning: Save still persists
|
||||
// the credential as-is.
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddApiKeyModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal");
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddApiKeyModal isOpen onSave={async () => undefined} onClose={() => {}} {...(props as any)} />
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// The validate endpoint reports the provider has no live validator (400 +
|
||||
// unsupported:true). Any other call (model lookups, etc.) succeeds.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
if (String(url).includes("/api/providers/validate")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
json: () =>
|
||||
Promise.resolve({ error: "Provider validation not supported", unsupported: true }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — 'validation not supported' is a non-blocking warning (#5565/#5567)", () => {
|
||||
it("still calls onSave when /validate returns unsupported=true", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "piapi", providerName: "PiAPI", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
expect(apiKeyInput).toBeTruthy();
|
||||
setInputValue(nameInput, "My PiAPI");
|
||||
setInputValue(apiKeyInput, "piapi-secret-key");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
expect(saveBtn).toBeTruthy();
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// RED on pre-fix code: the modal ignored `unsupported`, set a save error and
|
||||
// returned without calling onSave. After the fix it proceeds to save.
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// #5088 — When the inline credential "Check" fails, the modal showed only a bare
|
||||
// "invalid" badge and threw away the detailed reason returned by
|
||||
// /api/providers/validate. For claude-web/chatgpt-web the real cause is often an
|
||||
// environment error (e.g. "TLS impersonation client failed to start: EACCES …"),
|
||||
// which the backend already surfaces in `data.error` — but the UI hid it, so the
|
||||
// reporter had to dig it out via a separate Provider Test. The detailed message
|
||||
// must be shown next to the badge.
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddApiKeyModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal");
|
||||
|
||||
const TLS_EACCES_ERROR =
|
||||
"TLS impersonation client failed to start: EACCES: permission denied, mkdir " +
|
||||
"'/usr/lib/node_modules/omniroute/dist/node_modules/tls-client-node/bin'. " +
|
||||
"Verify tls-client-node is installed and its native binary downloaded. " +
|
||||
"(claude-web requires this — without it, Cloudflare blocks every request)";
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddApiKeyModal isOpen onSave={async () => undefined} onClose={() => {}} {...(props as any)} />
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// /api/providers/validate fails with the detailed TLS/EACCES reason; any other
|
||||
// call (e.g. model lookups) succeeds.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
if (String(url).includes("/api/providers/validate")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ valid: false, error: TLS_EACCES_ERROR }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — surfaces the detailed validation error (#5088)", () => {
|
||||
it("shows the underlying TLS/EACCES reason, not just an 'invalid' badge", async () => {
|
||||
const el = render({ provider: "claude-web", providerName: "Claude Web" });
|
||||
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
expect(apiKeyInput).toBeTruthy();
|
||||
setInputValue(apiKeyInput, "sk-ant-sid01-fake-session-key");
|
||||
|
||||
// The validate ("check") button is the first button that follows the
|
||||
// credential input in DOM order (it sits right next to it).
|
||||
const checkBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) =>
|
||||
(apiKeyInput.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
||||
)!;
|
||||
expect(checkBtn).toBeTruthy();
|
||||
act(() => {
|
||||
checkBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// The full reason must reach the DOM — a bare "invalid" badge is not enough.
|
||||
await waitFor(() => el.textContent?.includes("EACCES: permission denied") ?? false);
|
||||
expect(el.textContent).toContain("TLS impersonation client failed to start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UI unit tests for AgentBridge page — smoke render + empty state.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
|
||||
React.createElement("a", { href }, children),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch for hooks
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
} as unknown as Response);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmptyStateNoProviders", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state component with link to providers", async () => {
|
||||
const { EmptyStateNoProviders } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/EmptyStateNoProviders"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(EmptyStateNoProviders));
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).toContain("emptyNoProvidersTitle");
|
||||
expect(document.body.innerHTML).toContain("/dashboard/providers");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RiskNoticeBanner", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
try { localStorage.clear(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it("renders risk notice banner when not dismissed", async () => {
|
||||
// Ensure not dismissed
|
||||
try { localStorage.removeItem("omniroute-agentbridge-risk-dismissed"); } catch { /* ignore */ }
|
||||
|
||||
const { RiskNoticeBanner } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(RiskNoticeBanner));
|
||||
});
|
||||
|
||||
// Banner is rendered (useEffect fires synchronously in jsdom with act)
|
||||
// It shows "riskBannerTitle" i18n key
|
||||
expect(document.body.innerHTML).toContain("riskBannerTitle");
|
||||
});
|
||||
|
||||
it("does not render risk banner when already dismissed", async () => {
|
||||
try {
|
||||
localStorage.setItem("omniroute-agentbridge-risk-dismissed", "true");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const { RiskNoticeBanner } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(RiskNoticeBanner));
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).not.toContain("riskBannerTitle");
|
||||
});
|
||||
|
||||
it("dismisses banner on close click", async () => {
|
||||
try { localStorage.removeItem("omniroute-agentbridge-risk-dismissed"); } catch { /* ignore */ }
|
||||
|
||||
const { RiskNoticeBanner } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/RiskNoticeBanner"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(RiskNoticeBanner));
|
||||
});
|
||||
|
||||
// Click dismiss
|
||||
const closeBtn = container.querySelector('button[aria-label]');
|
||||
await act(async () => {
|
||||
closeBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Banner gone
|
||||
expect(document.body.innerHTML).not.toContain("riskBannerTitle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BypassListEditor", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders default bypass patterns", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: [],
|
||||
onSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).toContain("*.bank.*");
|
||||
expect(document.body.innerHTML).toContain("*.okta.com");
|
||||
});
|
||||
|
||||
it("renders initial user patterns in textarea", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: ["*.internal.corp"],
|
||||
onSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea?.value).toContain("*.internal.corp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* a11y tests for AgentBridgeServerCard — each action button must have aria-label.
|
||||
* Uses source-text inspection (no JSDOM render needed) for the structural assertion.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CARD_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeServerCard.tsx",
|
||||
);
|
||||
|
||||
const src = readFileSync(CARD_PATH, "utf-8");
|
||||
|
||||
describe("AgentBridgeServerCard aria-labels (B2)", () => {
|
||||
it("Start button has aria-label", () => {
|
||||
// Start button: onClick start, aria-label present
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("startServer")}'),
|
||||
'Start button must have aria-label={t("startServer")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Stop button has aria-label", () => {
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("stopServer")}'),
|
||||
'Stop button must have aria-label={t("stopServer")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Restart button has aria-label", () => {
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("restartServer")}'),
|
||||
'Restart button must have aria-label={t("restartServer")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Trust Cert button has aria-label", () => {
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("trustCert")}'),
|
||||
'Trust Cert button must have aria-label={t("trustCert")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Download Cert anchor has aria-label", () => {
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("downloadCert")}'),
|
||||
'Download Cert anchor must have aria-label={t("downloadCert")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Regenerate Cert button has aria-label", () => {
|
||||
assert.ok(
|
||||
src.includes('aria-label={t("regenerateCert")}'),
|
||||
'Regenerate Cert button must have aria-label={t("regenerateCert")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("all 5 buttons and 1 anchor have aria-label attributes (6 total)", () => {
|
||||
// Count aria-label occurrences in action buttons section
|
||||
const matches = src.match(/aria-label=\{t\(/g) ?? [];
|
||||
assert.ok(
|
||||
matches.length >= 6,
|
||||
`Expected at least 6 aria-label attributes, found ${matches.length}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRecorderBar aria-labels (B2)", () => {
|
||||
const BAR_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionRecorderBar.tsx",
|
||||
);
|
||||
const barSrc = readFileSync(BAR_PATH, "utf-8");
|
||||
|
||||
it("REC (recordSession) button has aria-label", () => {
|
||||
assert.ok(
|
||||
barSrc.includes('aria-label={t("recordSession")}'),
|
||||
'REC button must have aria-label={t("recordSession")}',
|
||||
);
|
||||
});
|
||||
|
||||
it("Stop (stopSession) button has aria-label", () => {
|
||||
assert.ok(
|
||||
barSrc.includes('aria-label={t("stopSession")}'),
|
||||
'Stop button must have aria-label={t("stopSession")}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AgentCard — per-agent RiskNoticeModal on first DNS activation.
|
||||
*
|
||||
* Covers:
|
||||
* - First toggle opens modal (does NOT call onDnsToggle immediately)
|
||||
* - Accept closes modal + calls onDnsToggle with true
|
||||
* - Second activation does NOT open modal (localStorage flag set)
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
|
||||
React.createElement("a", { href }, children),
|
||||
}));
|
||||
|
||||
// Button.tsx exposes a default export — match the real module shape so
|
||||
// RiskNoticeModal (which uses `import Button from ...`) resolves correctly.
|
||||
// Round 3 had this as a named-export mock, which masked the production
|
||||
// `import { Button }` bug fixed in R4 #1.
|
||||
vi.mock("@/shared/components/Button", () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: string;
|
||||
}) => React.createElement("button", { type: "button", onClick }, children),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const RISK_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
const mockTarget = {
|
||||
id: "copilot" as const,
|
||||
name: "GitHub Copilot",
|
||||
icon: "code",
|
||||
color: "#10B981",
|
||||
hosts: ["api.githubcopilot.com"],
|
||||
port: 443,
|
||||
endpointPatterns: ["/chat/completions"],
|
||||
defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }],
|
||||
setupTutorial: {
|
||||
steps: ["Step 1", "Step 2"],
|
||||
detection: { command: "which copilot", platform: "all" as const },
|
||||
},
|
||||
handler: () => Promise.resolve({ default: class {} as never }),
|
||||
riskNoticeKey: "oauth",
|
||||
viability: "supported" as const,
|
||||
};
|
||||
|
||||
const baseAgentState = {
|
||||
agent_id: "copilot",
|
||||
dns_enabled: false,
|
||||
cert_trusted: true,
|
||||
setup_completed: true,
|
||||
last_started_at: null,
|
||||
last_error: null,
|
||||
};
|
||||
|
||||
describe("AgentCard RiskNoticeModal", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
// Clear localStorage risk key before each test
|
||||
try {
|
||||
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
try {
|
||||
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("first DNS activation opens risk modal (does NOT call onDnsToggle yet)", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: baseAgentState,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Click DNS toggle (Start DNS)
|
||||
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
expect(dnsBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Risk modal should be open (dialog element present)
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
// onDnsToggle should NOT have been called yet
|
||||
expect(onDnsToggle).not.toHaveBeenCalled();
|
||||
}, 30000);
|
||||
|
||||
it("accepting risk modal closes modal and calls onDnsToggle with true", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: baseAgentState,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Click DNS toggle to open modal
|
||||
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
await act(async () => {
|
||||
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Modal should be open
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
// Click "I understand" (accept) button — uses t("understand") key
|
||||
const acceptBtn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("understand")
|
||||
);
|
||||
expect(acceptBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
acceptBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Modal should be closed
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
// onDnsToggle should have been called with true
|
||||
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
|
||||
|
||||
// localStorage flag should be set
|
||||
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
|
||||
expect(stored).toBe("true");
|
||||
}, 30000);
|
||||
|
||||
it("accepting risk writes localStorage exactly once (RiskNoticeModal is sole writer)", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: baseAgentState,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Click DNS toggle to open modal
|
||||
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
await act(async () => {
|
||||
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Accept modal
|
||||
const acceptBtn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("understand")
|
||||
);
|
||||
await act(async () => {
|
||||
acceptBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const riskKey = "omniroute-agentbridge-risk-dismissed-copilot";
|
||||
const riskWrites = setItemSpy.mock.calls.filter(([key]) => key === riskKey);
|
||||
// Must be exactly ONE write — RiskNoticeModal is the sole persistence owner (D16)
|
||||
expect(riskWrites).toHaveLength(1);
|
||||
expect(riskWrites[0][1]).toBe("true");
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
it("second DNS activation does NOT open modal when localStorage flag is set", async () => {
|
||||
// Pre-set the localStorage flag (simulates accepted risk on previous session)
|
||||
try {
|
||||
localStorage.setItem(RISK_KEY_PREFIX + "copilot", "true");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: baseAgentState,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Click DNS toggle
|
||||
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
await act(async () => {
|
||||
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Risk modal should NOT appear
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
// onDnsToggle should have been called directly (no modal gate)
|
||||
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
|
||||
}, 30000);
|
||||
|
||||
it("cancelling risk modal keeps modal closed and does NOT call onDnsToggle", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: baseAgentState,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Click DNS toggle to open modal
|
||||
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
await act(async () => {
|
||||
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Modal should be open
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
// Click Cancel button
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("cancel")
|
||||
);
|
||||
expect(cancelBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
cancelBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Modal should be closed
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
// onDnsToggle should NOT have been called
|
||||
expect(onDnsToggle).not.toHaveBeenCalled();
|
||||
|
||||
// localStorage flag should NOT be set (user cancelled)
|
||||
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
|
||||
expect(stored).toBeNull();
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UI unit tests for AgentCard — DNS toggle + wizard open.
|
||||
*
|
||||
* Note: AgentCard/SetupWizard import @/mitm/types (zod types only, no DB).
|
||||
* We set testTimeout=30000 to handle the initial transform overhead.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
|
||||
React.createElement("a", { href }, children),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
} as unknown as Response);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
// Minimal mock target (matches MitmTarget shape but no heavy imports)
|
||||
const mockTarget = {
|
||||
id: "copilot" as const,
|
||||
name: "GitHub Copilot",
|
||||
icon: "code",
|
||||
color: "#10B981",
|
||||
hosts: ["api.githubcopilot.com"],
|
||||
port: 443,
|
||||
endpointPatterns: ["/chat/completions"],
|
||||
defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }],
|
||||
setupTutorial: {
|
||||
steps: ["Step 1", "Step 2"],
|
||||
detection: { command: "which copilot", platform: "all" as const },
|
||||
},
|
||||
handler: () => Promise.resolve({ default: class {} as never }),
|
||||
riskNoticeKey: "providers.riskNotice.oauth",
|
||||
viability: "supported" as const,
|
||||
};
|
||||
|
||||
describe("AgentCard", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders agent name and hosts", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: undefined,
|
||||
serverRunning: false,
|
||||
mappings: [],
|
||||
onDnsToggle: vi.fn(),
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).toContain("GitHub Copilot");
|
||||
expect(document.body.innerHTML).toContain("api.githubcopilot.com");
|
||||
}, 30000);
|
||||
|
||||
it("expands on click and shows DNS toggle", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: undefined,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle: vi.fn(),
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
expect(header).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).toContain("startDns");
|
||||
}, 30000);
|
||||
|
||||
it("calls onDnsToggle when DNS button clicked", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
// Simulate that the per-agent RiskNoticeModal (Fix4 M5) has already been
|
||||
// accepted for this agent — otherwise the DNS click opens the modal first
|
||||
// and onDnsToggle is only called after the user accepts. We test the
|
||||
// "already accepted" path here; the modal flow is covered by
|
||||
// tests/unit/ui/agent-card-risk-modal.test.tsx.
|
||||
localStorage.setItem("omniroute-agentbridge-risk-dismissed-copilot", "true");
|
||||
|
||||
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: {
|
||||
agent_id: "copilot",
|
||||
dns_enabled: false,
|
||||
cert_trusted: false,
|
||||
setup_completed: false,
|
||||
last_started_at: null,
|
||||
last_error: null,
|
||||
},
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle,
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Find and click DNS button
|
||||
const dnsButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("startDns")
|
||||
);
|
||||
expect(dnsButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
dnsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
|
||||
}, 30000);
|
||||
|
||||
it("opens wizard when setup wizard button clicked", async () => {
|
||||
const { AgentCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(AgentCard, {
|
||||
target: mockTarget,
|
||||
agentState: undefined,
|
||||
serverRunning: true,
|
||||
mappings: [],
|
||||
onDnsToggle: vi.fn(),
|
||||
onMappingsSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Expand card first
|
||||
const header = container.querySelector("button[aria-expanded]");
|
||||
await act(async () => {
|
||||
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Find setup wizard button
|
||||
const wizardBtn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("setupWizard")
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wizardBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AllocationTable } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable"
|
||||
);
|
||||
|
||||
const ALLOCATIONS = [
|
||||
{ apiKeyId: "key_1", weight: 60, policy: "hard" as const },
|
||||
{ apiKeyId: "key_2", weight: 40, policy: "soft" as const },
|
||||
];
|
||||
|
||||
const KEY_LABELS: Record<string, string> = { key_1: "KeyOne", key_2: "KeyTwo" };
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function render(props: Parameters<typeof AllocationTable>[0]) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<AllocationTable {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("AllocationTable", { timeout: 10000 }, () => {
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders empty state when no allocations", async () => {
|
||||
await render({ allocations: [], usage: null, keyLabels: {} });
|
||||
expect(document.body.innerHTML).toContain("noAllocations");
|
||||
});
|
||||
|
||||
it("renders key labels", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("KeyOne");
|
||||
expect(document.body.innerHTML).toContain("KeyTwo");
|
||||
});
|
||||
|
||||
it("renders weights correctly", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("60%");
|
||||
expect(document.body.innerHTML).toContain("40%");
|
||||
});
|
||||
|
||||
it("renders policy badges", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("hard");
|
||||
expect(document.body.innerHTML).toContain("soft");
|
||||
});
|
||||
|
||||
it("renders consumed values from usage perKey data", async () => {
|
||||
const usage = {
|
||||
dimensions: [
|
||||
{
|
||||
unit: "tokens",
|
||||
window: "daily",
|
||||
limit: 1000,
|
||||
consumedTotal: 400,
|
||||
perKey: [
|
||||
{ apiKeyId: "key_1", consumed: 300, fairShare: 600, deficit: -300, borrowing: false },
|
||||
{ apiKeyId: "key_2", consumed: 100, fairShare: 400, deficit: 300, borrowing: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
burnRate: null,
|
||||
};
|
||||
await render({ allocations: ALLOCATIONS, usage: usage as never, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("300");
|
||||
expect(document.body.innerHTML).toContain("100");
|
||||
// borrowing indicator for key_2
|
||||
expect(document.body.innerHTML).toContain("borrowingIndicator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UI unit test for the AuthzSection wildcard-CORS banner (#5602).
|
||||
*
|
||||
* The banner must appear when `/api/settings/authz-inventory` reports
|
||||
* `cors.allowAll === true` (i.e. `CORS_ALLOW_ALL=true` at runtime) and stay
|
||||
* hidden otherwise. It is the only runtime signal a wildcard-CORS
|
||||
* misconfiguration is live. See docs/security/CORS.md.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stable identity fn — the real next-intl `t` is memoized per render. A fresh
|
||||
// closure each render would flip AuthzSection's `useCallback([t])` dep and loop
|
||||
// its mount fetch forever, so we return the SAME reference every call.
|
||||
const translate = (key: string) => key;
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => translate,
|
||||
}));
|
||||
|
||||
// Import the (heavy) dashboard component ONCE at module load rather than inside every
|
||||
// render call. The dynamic import pulls the whole settings-page dependency graph through
|
||||
// esbuild on first use (~20s cold); doing it per-test made the first test tip over the
|
||||
// 30s per-test timeout while the second (warm, cached) passed. Hoisting moves that cost to
|
||||
// module-eval time (outside any per-test timeout) and keeps each test body fast.
|
||||
const AuthzSection = (
|
||||
await import("../../../src/app/(dashboard)/dashboard/settings/components/AuthzSection")
|
||||
).default;
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
function inventoryPayload(cors: { allowAll: boolean; allowedOrigins: string[] }) {
|
||||
return {
|
||||
tiers: [
|
||||
{
|
||||
name: "PUBLIC",
|
||||
prefixes: ["/api/health"],
|
||||
description: "public",
|
||||
bypassable: false,
|
||||
},
|
||||
],
|
||||
bypassEnabled: true,
|
||||
bypassPrefixes: ["/api/mcp/"],
|
||||
spawnCapablePrefixes: ["/api/cli-tools/runtime/"],
|
||||
cors,
|
||||
};
|
||||
}
|
||||
|
||||
function mockInventoryFetch(cors: { allowAll: boolean; allowedOrigins: string[] }) {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(inventoryPayload(cors)),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
async function renderAuthzSection(): Promise<HTMLElement> {
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
createRoot(container).render(React.createElement(AuthzSection));
|
||||
});
|
||||
// Flush the mount-time inventory fetch + resulting state update.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AuthzSection wildcard-CORS banner (#5602)", { timeout: 60000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the banner when cors.allowAll is true", async () => {
|
||||
mockInventoryFetch({ allowAll: true, allowedOrigins: [] });
|
||||
await renderAuthzSection();
|
||||
const banner = document.querySelector('[data-testid="cors-wildcard-banner"]');
|
||||
expect(banner).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render the banner when cors.allowAll is false", async () => {
|
||||
mockInventoryFetch({ allowAll: false, allowedOrigins: ["https://app.example.com"] });
|
||||
await renderAuthzSection();
|
||||
const banner = document.querySelector('[data-testid="cors-wildcard-banner"]');
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Stub next/dynamic — returns null component (recharts not needed in tests)
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => () => null,
|
||||
}));
|
||||
|
||||
const { default: BurnRateChart } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart"
|
||||
);
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function render(props: Parameters<typeof BurnRateChart>[0]) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<BurnRateChart {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("BurnRateChart", { timeout: 10000 }, () => {
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders no-data state when usage is null", async () => {
|
||||
await render({ usage: null });
|
||||
expect(document.body.innerHTML).toContain("burnRateTitle");
|
||||
expect(document.body.innerHTML).toContain("no data");
|
||||
});
|
||||
|
||||
it("renders no-data state when burnRate is falsy", async () => {
|
||||
const usage = {
|
||||
dimensions: [],
|
||||
burnRate: null,
|
||||
};
|
||||
await render({ usage: usage as never });
|
||||
expect(document.body.innerHTML).toContain("no data");
|
||||
});
|
||||
|
||||
it("renders chart when usage has burnRate data", async () => {
|
||||
const usage = {
|
||||
dimensions: [
|
||||
{ unit: "tokens", window: "daily", limit: 100000, consumedTotal: 30000, perKey: [] },
|
||||
],
|
||||
burnRate: { tokensPerSecond: 10, timeToExhaustionMs: 7_000_000 },
|
||||
};
|
||||
await render({ usage: usage as never });
|
||||
// Should not show no-data message
|
||||
expect(document.body.innerHTML).not.toContain("no data yet");
|
||||
// Should show exhaustion label
|
||||
expect(document.body.innerHTML).toContain("burnRateExhaustsIn");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UI unit tests for BypassListEditor — add/remove patterns.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("BypassListEditor", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders default bypass patterns as read-only chips", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: [],
|
||||
onSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).toContain("*.bank.*");
|
||||
expect(document.body.innerHTML).toContain("*.okta.com");
|
||||
}, 30000);
|
||||
|
||||
it("renders initial user patterns in textarea", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: ["*.internal.corp", "sso.example.com"],
|
||||
onSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea?.value).toContain("*.internal.corp");
|
||||
expect(textarea?.value).toContain("sso.example.com");
|
||||
}, 30000);
|
||||
|
||||
it("calls onSave when Save button clicked", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const container = makeContainer();
|
||||
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: ["existing.com"],
|
||||
onSave,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("saveBypassList")
|
||||
);
|
||||
expect(saveBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
saveBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
}, 30000);
|
||||
|
||||
it("renders save button", async () => {
|
||||
const { BypassListEditor } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/BypassListEditor"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
React.createElement(BypassListEditor, {
|
||||
patterns: [],
|
||||
onSave: vi.fn(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("saveBypassList")
|
||||
);
|
||||
expect(saveBtn).not.toBeNull();
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub ToolDetailClient — renders a testid with props
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
|
||||
default: ({ toolId, category }: { toolId: string; category: string }) => (
|
||||
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliAgentsDetailPage } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-agents/[id]/page"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
|
||||
let notFoundThrown = false;
|
||||
let jsx: React.ReactNode | null = null;
|
||||
|
||||
try {
|
||||
jsx = await CliAgentsDetailPage({ params: Promise.resolve({ id }) });
|
||||
} catch (err: any) {
|
||||
if (err?.message === "NOT_FOUND") {
|
||||
notFoundThrown = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
if (!notFoundThrown && jsx) {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(jsx as React.ReactElement);
|
||||
});
|
||||
}
|
||||
|
||||
return { container, notFound: notFoundThrown };
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliAgentsDetailPage", () => {
|
||||
it("renders ToolDetailClient for /dashboard/cli-agents/hermes-agent (category:agent)", async () => {
|
||||
const { container, notFound } = await renderPage("hermes-agent");
|
||||
expect(notFound).toBe(false);
|
||||
const el = container.querySelector("[data-testid='ToolDetailClient']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.getAttribute("data-toolid")).toBe("hermes-agent");
|
||||
expect(el!.getAttribute("data-category")).toBe("agent");
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-agents/claude (category:code — cross-category)", async () => {
|
||||
const { notFound } = await renderPage("claude");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-agents/invalid-id (unknown tool)", async () => {
|
||||
const { notFound } = await renderPage("invalid-id-xyz");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let notFoundCalled = false;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
notFoundCalled = true;
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/constants/models", () => ({
|
||||
PROVIDER_ID_TO_ALIAS: {},
|
||||
getModelsByProviderId: () => [],
|
||||
}));
|
||||
|
||||
// Stub ToolDetailClient — just renders a testid with the received props
|
||||
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({
|
||||
default: ({ toolId, category }: { toolId: string; category: string }) => (
|
||||
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: CliCodeDetailPage } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/[id]/page"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> {
|
||||
let notFoundThrown = false;
|
||||
let jsx: React.ReactNode | null = null;
|
||||
|
||||
try {
|
||||
jsx = await CliCodeDetailPage({ params: Promise.resolve({ id }) });
|
||||
} catch (err: any) {
|
||||
if (err?.message === "NOT_FOUND") {
|
||||
notFoundThrown = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
if (!notFoundThrown && jsx) {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(jsx as React.ReactElement);
|
||||
});
|
||||
}
|
||||
|
||||
return { container, notFound: notFoundThrown };
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
notFoundCalled = false;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CliCodeDetailPage", () => {
|
||||
it("renders ToolDetailClient for /dashboard/cli-code/claude (category:code)", async () => {
|
||||
const { container, notFound } = await renderPage("claude");
|
||||
expect(notFound).toBe(false);
|
||||
const el = container.querySelector("[data-testid='ToolDetailClient']");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.getAttribute("data-toolid")).toBe("claude");
|
||||
expect(el!.getAttribute("data-category")).toBe("code");
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-code/hermes-agent (category:agent — cross-category)", async () => {
|
||||
const { notFound } = await renderPage("hermes-agent");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for /dashboard/cli-code/invalid-id (unknown tool)", async () => {
|
||||
const { notFound } = await renderPage("invalid-id-xyz");
|
||||
expect(notFound).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Regression guard: /dashboard/cli-code must NOT contain MITM UI.
|
||||
* MITM setup now lives exclusively in AgentBridge (plan 11 §12 #10, R5-2).
|
||||
*
|
||||
* Uses source-text inspection — no JSDOM render needed.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAGE_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../src/app/(dashboard)/dashboard/cli-code/CliCodePageClient.tsx"
|
||||
);
|
||||
|
||||
const src = readFileSync(PAGE_PATH, "utf-8");
|
||||
|
||||
describe("CliCodePageClient — no MITM duplication (R5-2)", () => {
|
||||
it("MITM_TOOL_IDS constant is not defined", () => {
|
||||
assert.ok(
|
||||
!src.includes("MITM_TOOL_IDS"),
|
||||
"MITM_TOOL_IDS must be removed from CliCodePageClient.tsx"
|
||||
);
|
||||
});
|
||||
|
||||
it("mitm tab value is not present in SegmentedControl options", () => {
|
||||
assert.ok(
|
||||
!src.includes('value: "mitm"'),
|
||||
'Tab entry { value: "mitm" } must be removed from CliCodePageClient.tsx'
|
||||
);
|
||||
});
|
||||
|
||||
it("mitmClientsTab i18n key is not referenced in render", () => {
|
||||
assert.ok(
|
||||
!src.includes('t("mitmClientsTab")'),
|
||||
"mitmClientsTab must not be called in CliCodePageClient.tsx"
|
||||
);
|
||||
});
|
||||
|
||||
it("AntigravityToolCard is not imported", () => {
|
||||
assert.ok(
|
||||
!src.includes("AntigravityToolCard"),
|
||||
"AntigravityToolCard import must be removed from CLIToolsPageClient.tsx"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// #5598 — Selecting the "fusion" routing strategy on the Global Routing defaults
|
||||
// tab previously revealed no fusion-specific config (only the generic resilience
|
||||
// fields). Fusion's engine knobs (judgeModel + fusionTuning) exist in the schema
|
||||
// and the per-combo editor, but were never surfaced as global defaults. This
|
||||
// asserts they now appear when fusion is the selected strategy.
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
// identity translator with no `has`, so translateOrFallback uses the English fallbacks
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ComboDefaultsTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab"
|
||||
);
|
||||
|
||||
function okJson(data: unknown) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(data) } as Response);
|
||||
}
|
||||
|
||||
function setupFetch(strategy: string) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
if (String(url).includes("/combo-defaults")) return okJson({ comboDefaults: { strategy } });
|
||||
if (String(url).includes("/api/providers")) return okJson({ connections: [] });
|
||||
return okJson({}); // /api/settings
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderTab() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ComboDefaultsTab />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ComboDefaultsTab fusion config (#5598)", () => {
|
||||
it("shows fusion-specific fields when the fusion strategy is selected", async () => {
|
||||
setupFetch("fusion");
|
||||
const el = renderTab();
|
||||
// RED before the fix: fusion had no config block → these labels never render.
|
||||
await waitFor(() => el.textContent?.includes("Judge Model") === true);
|
||||
expect(el.textContent).toContain("Judge Model");
|
||||
expect(el.textContent).toContain("Min Panel");
|
||||
expect(el.textContent).toContain("Straggler Grace (ms)");
|
||||
expect(el.textContent).toContain("Panel Hard Timeout (ms)");
|
||||
});
|
||||
|
||||
it("does not show fusion fields for a non-fusion strategy", async () => {
|
||||
setupFetch("priority");
|
||||
const el = renderTab();
|
||||
// Let the component settle (load + render).
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(el.textContent).not.toContain("Judge Model");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Pure unit test for the deselect filter semantics used by ComboFormModal in
|
||||
// src/app/(dashboard)/dashboard/combos/page.tsx — ported from upstream PR
|
||||
// decolua/9router#889 (Fajar Hidayat).
|
||||
//
|
||||
// The page-level handler removes every step whose qualified `model` matches
|
||||
// the value sent from the ModelSelectModal, matching upstream JS behavior
|
||||
// (`setModels(models.filter((m) => m !== model.value))`). Duplicates with
|
||||
// different providerId/weight pointing at the same model id are all stripped.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Step = { model: string; providerId?: string; weight?: number };
|
||||
|
||||
// Mirror of src/app/(dashboard)/dashboard/combos/page.tsx::handleDeselectModel.
|
||||
// Kept in lockstep so the same filter behavior is checked here, leaf-only.
|
||||
function deselectModel(models: Step[], model: { value?: string } | string): Step[] {
|
||||
const value =
|
||||
typeof (model as any)?.value === "string"
|
||||
? (model as any).value
|
||||
: typeof model === "string"
|
||||
? model
|
||||
: "";
|
||||
if (!value) return models;
|
||||
return models.filter((m) => m.model !== value);
|
||||
}
|
||||
|
||||
describe("ComboFormModal deselect handler (upstream PR #889)", () => {
|
||||
it("removes the single matching step when called with { value }", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", weight: 50 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 50 },
|
||||
];
|
||||
const next = deselectModel(models, { value: "openai/gpt-4o" });
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 50 }]);
|
||||
});
|
||||
|
||||
it("strips every duplicate of the same qualified model", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", providerId: "openai-a", weight: 30 },
|
||||
{ model: "openai/gpt-4o", providerId: "openai-b", weight: 70 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 0 },
|
||||
];
|
||||
const next = deselectModel(models, { value: "openai/gpt-4o" });
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 0 }]);
|
||||
});
|
||||
|
||||
it("is a no-op when the value is not in the list", () => {
|
||||
const models: Step[] = [{ model: "openai/gpt-4o", weight: 100 }];
|
||||
const next = deselectModel(models, { value: "openai/gpt-3.5" });
|
||||
expect(next).toEqual(models);
|
||||
});
|
||||
|
||||
it("is a no-op for empty/missing value (defensive guard)", () => {
|
||||
const models: Step[] = [{ model: "openai/gpt-4o", weight: 100 }];
|
||||
expect(deselectModel(models, { value: "" })).toEqual(models);
|
||||
expect(deselectModel(models, {})).toEqual(models);
|
||||
});
|
||||
|
||||
it("accepts a raw string model identifier (legacy upstream call shape)", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", weight: 50 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 50 },
|
||||
];
|
||||
const next = deselectModel(models, "openai/gpt-4o");
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 50 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ResponseValidationEditor,
|
||||
type ResponseValidationValue,
|
||||
} from "@/app/(dashboard)/dashboard/combos/ResponseValidationEditor";
|
||||
|
||||
// Feature 4985 — the per-combo response-validation editor emits the declarative shape.
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function render(value: ResponseValidationValue | undefined, onChange: (v: unknown) => void) {
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<ResponseValidationEditor value={value} onChange={onChange} />);
|
||||
});
|
||||
}
|
||||
|
||||
function setTextarea(testid: string, text: string) {
|
||||
const el = container.querySelector<HTMLTextAreaElement>(`[data-testid="${testid}"]`);
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(el, text);
|
||||
el!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("ResponseValidationEditor (4985)", () => {
|
||||
it("turns forbidden-substring lines into an array (trimmed, no blanks)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(undefined, onChange);
|
||||
setTextarea("rv-forbidden", "I cannot help\n\n as an AI \n");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
forbiddenSubstrings: ["I cannot help", "as an AI"],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the whole config back to undefined when every field is emptied", () => {
|
||||
const onChange = vi.fn();
|
||||
render({ forbiddenSubstrings: ["x"] }, onChange);
|
||||
setTextarea("rv-forbidden", "");
|
||||
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("adds a json-path predicate row with sane defaults", () => {
|
||||
const onChange = vi.fn();
|
||||
render(undefined, onChange);
|
||||
const addBtn = container.querySelector<HTMLButtonElement>('[data-testid="rv-predicate-add"]');
|
||||
act(() => addBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
jsonPathPredicates: [{ path: "", condition: "exists" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders existing predicate rows from the value", () => {
|
||||
render(
|
||||
{ jsonPathPredicates: [{ path: "choices[0].message.content", condition: "nonEmpty" }] },
|
||||
vi.fn()
|
||||
);
|
||||
const rows = container.querySelectorAll('[data-testid="rv-predicate-row"]');
|
||||
expect(rows.length).toBe(1);
|
||||
const pathInput = container.querySelector<HTMLInputElement>('[data-testid="rv-predicate-path"]');
|
||||
expect(pathInput?.value).toBe("choices[0].message.content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* tests/unit/ui/comboFlowModel-breakers.test.ts
|
||||
*
|
||||
* TDD for U1b — enrichRunWithBreakers: overlays REAL circuit-breaker state
|
||||
* (from GET /api/monitoring/health → providerHealth[provider]) onto a combo run's
|
||||
* targets, so the cascade can show "CB: OPEN · retry 41s" instead of only the
|
||||
* error-string heuristic.
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/comboFlowModel-breakers.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
enrichRunWithBreakers,
|
||||
comboRunToFlow,
|
||||
type ComboRunModel,
|
||||
type TargetNodeModel,
|
||||
} from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
function mkRun(
|
||||
targets: Array<Partial<TargetNodeModel> & { provider: string }>
|
||||
): ComboRunModel {
|
||||
return {
|
||||
comboName: "c",
|
||||
strategy: "priority",
|
||||
outcome: "running",
|
||||
startedAt: 0,
|
||||
targets: targets.map((t, i) => ({
|
||||
targetIndex: i,
|
||||
provider: t.provider,
|
||||
model: t.model ?? "m",
|
||||
state: t.state ?? "idle",
|
||||
...t,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("enrichRunWithBreakers", () => {
|
||||
it("returns null for a null run", () => {
|
||||
assert.equal(enrichRunWithBreakers(null, {}), null);
|
||||
});
|
||||
|
||||
it("returns the same run reference when no health map is provided", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
assert.equal(enrichRunWithBreakers(run, null), run);
|
||||
assert.equal(enrichRunWithBreakers(run, undefined), run);
|
||||
});
|
||||
|
||||
it("attaches cbState + retryAfterMs for an OPEN provider breaker only", () => {
|
||||
const run = mkRun([{ provider: "openai" }, { provider: "anthropic" }]);
|
||||
const out = enrichRunWithBreakers(run, {
|
||||
openai: { state: "OPEN", retryAfterMs: 41000 },
|
||||
});
|
||||
assert.ok(out);
|
||||
assert.equal(out.targets[0].cbState, "OPEN");
|
||||
assert.equal(out.targets[0].cbRetryAfterMs, 41000);
|
||||
assert.equal(out.targets[1].cbState, undefined, "healthy/absent provider gets no badge");
|
||||
});
|
||||
|
||||
it("does not attach a badge for a CLOSED (healthy) breaker", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
const out = enrichRunWithBreakers(run, { openai: { state: "CLOSED", retryAfterMs: 0 } });
|
||||
assert.equal(out?.targets[0].cbState, undefined);
|
||||
});
|
||||
|
||||
it("surfaces HALF_OPEN and DEGRADED states (case-insensitive)", () => {
|
||||
const run = mkRun([{ provider: "a" }, { provider: "b" }]);
|
||||
const out = enrichRunWithBreakers(run, {
|
||||
a: { state: "half_open", retryAfterMs: 5000 },
|
||||
b: { state: "DEGRADED" },
|
||||
});
|
||||
assert.equal(out?.targets[0].cbState, "HALF_OPEN");
|
||||
assert.equal(out?.targets[1].cbState, "DEGRADED");
|
||||
});
|
||||
|
||||
it("does not mutate the input run (pure)", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
enrichRunWithBreakers(run, { openai: { state: "OPEN", retryAfterMs: 1 } });
|
||||
assert.equal(run.targets[0].cbState, undefined, "original run must be untouched");
|
||||
});
|
||||
|
||||
it("strips a stale cbState when the breaker recovers to CLOSED", () => {
|
||||
const run = mkRun([{ provider: "openai", cbState: "OPEN", cbRetryAfterMs: 9 }]);
|
||||
const out = enrichRunWithBreakers(run, { openai: { state: "CLOSED" } });
|
||||
assert.equal(out?.targets[0].cbState, undefined);
|
||||
assert.equal(out?.targets[0].cbRetryAfterMs, undefined);
|
||||
});
|
||||
|
||||
it("ignores unknown breaker state strings", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
const out = enrichRunWithBreakers(run, { openai: { state: "WEIRD" } });
|
||||
assert.equal(out?.targets[0].cbState, undefined);
|
||||
});
|
||||
|
||||
it("comboRunToFlow carries cbState/cbRetryAfterMs into the target node data", () => {
|
||||
const run = mkRun([{ provider: "openai" }, { provider: "anthropic" }]);
|
||||
const enriched = enrichRunWithBreakers(run, {
|
||||
openai: { state: "OPEN", retryAfterMs: 41000 },
|
||||
});
|
||||
const { nodes } = comboRunToFlow(enriched as ComboRunModel);
|
||||
|
||||
const target0 = nodes.find((n) => n.id === "target-0");
|
||||
assert.equal(target0?.data.cbState, "OPEN");
|
||||
assert.equal(target0?.data.cbRetryAfterMs, 41000);
|
||||
|
||||
const target1 = nodes.find((n) => n.id === "target-1");
|
||||
assert.equal(target1?.data.cbState, undefined, "healthy provider node has no cbState");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* tests/unit/ui/comboFlowModel-cooldown.test.ts
|
||||
*
|
||||
* TDD for F5.1 (Combo U1b Slice 2) — enrichRunWithConnectionCooldown: overlays the
|
||||
* real per-provider connection-cooldown summary (from GET /api/monitoring/health →
|
||||
* connectionHealth[provider]) onto a combo run's targets, so the cascade can badge
|
||||
* "cooldown 2/3 · 28s" alongside the circuit-breaker badge.
|
||||
*
|
||||
* Mirrors comboFlowModel-breakers.test.ts (Slice 1).
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/comboFlowModel-cooldown.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
enrichRunWithConnectionCooldown,
|
||||
comboRunToFlow,
|
||||
type ComboRunModel,
|
||||
type TargetNodeModel,
|
||||
} from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
function mkRun(targets: Array<Partial<TargetNodeModel> & { provider: string }>): ComboRunModel {
|
||||
return {
|
||||
comboName: "c",
|
||||
strategy: "priority",
|
||||
outcome: "running",
|
||||
startedAt: 0,
|
||||
targets: targets.map((t, i) => ({
|
||||
targetIndex: i,
|
||||
provider: t.provider,
|
||||
model: t.model ?? "m",
|
||||
state: t.state ?? "idle",
|
||||
...t,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("enrichRunWithConnectionCooldown", () => {
|
||||
it("returns null for a null run", () => {
|
||||
assert.equal(enrichRunWithConnectionCooldown(null, {}), null);
|
||||
});
|
||||
|
||||
it("returns the same run reference when no health map is provided", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
assert.equal(enrichRunWithConnectionCooldown(run, null), run);
|
||||
assert.equal(enrichRunWithConnectionCooldown(run, undefined), run);
|
||||
});
|
||||
|
||||
it("attaches cooldown count/total/retry for a provider with cooling connections", () => {
|
||||
const run = mkRun([{ provider: "anthropic" }, { provider: "openai" }]);
|
||||
const out = enrichRunWithConnectionCooldown(run, {
|
||||
anthropic: { coolingDown: 2, total: 3, soonestRetryAfterMs: 28_000 },
|
||||
});
|
||||
assert.ok(out);
|
||||
assert.equal(out.targets[0].cooldownCount, 2);
|
||||
assert.equal(out.targets[0].cooldownTotal, 3);
|
||||
assert.equal(out.targets[0].cooldownRetryAfterMs, 28_000);
|
||||
assert.equal(
|
||||
out.targets[1].cooldownCount,
|
||||
undefined,
|
||||
"provider with no cooldown gets no badge"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not attach a badge when coolingDown is 0 or absent", () => {
|
||||
const run = mkRun([{ provider: "a" }, { provider: "b" }]);
|
||||
const out = enrichRunWithConnectionCooldown(run, {
|
||||
a: { coolingDown: 0, total: 2, soonestRetryAfterMs: 0 },
|
||||
b: {},
|
||||
});
|
||||
assert.equal(out?.targets[0].cooldownCount, undefined);
|
||||
assert.equal(out?.targets[1].cooldownCount, undefined);
|
||||
});
|
||||
|
||||
it("does not mutate the input run (pure)", () => {
|
||||
const run = mkRun([{ provider: "openai" }]);
|
||||
enrichRunWithConnectionCooldown(run, {
|
||||
openai: { coolingDown: 1, total: 1, soonestRetryAfterMs: 5000 },
|
||||
});
|
||||
assert.equal(run.targets[0].cooldownCount, undefined, "original run must be untouched");
|
||||
});
|
||||
|
||||
it("strips a stale cooldown badge when the provider's connections recover", () => {
|
||||
const run = mkRun([
|
||||
{ provider: "openai", cooldownCount: 2, cooldownTotal: 3, cooldownRetryAfterMs: 9000 },
|
||||
]);
|
||||
const out = enrichRunWithConnectionCooldown(run, {
|
||||
openai: { coolingDown: 0, total: 3, soonestRetryAfterMs: 0 },
|
||||
});
|
||||
assert.equal(out?.targets[0].cooldownCount, undefined);
|
||||
assert.equal(out?.targets[0].cooldownTotal, undefined);
|
||||
assert.equal(out?.targets[0].cooldownRetryAfterMs, undefined);
|
||||
});
|
||||
|
||||
it("comboRunToFlow carries the cooldown fields into the target node data", () => {
|
||||
const run = mkRun([{ provider: "anthropic" }, { provider: "openai" }]);
|
||||
const enriched = enrichRunWithConnectionCooldown(run, {
|
||||
anthropic: { coolingDown: 1, total: 2, soonestRetryAfterMs: 12_000 },
|
||||
});
|
||||
const { nodes } = comboRunToFlow(enriched as ComboRunModel);
|
||||
|
||||
const target0 = nodes.find((n) => n.id === "target-0");
|
||||
assert.equal(target0?.data.cooldownCount, 1);
|
||||
assert.equal(target0?.data.cooldownTotal, 2);
|
||||
assert.equal(target0?.data.cooldownRetryAfterMs, 12_000);
|
||||
|
||||
const target1 = nodes.find((n) => n.id === "target-1");
|
||||
assert.equal(target1?.data.cooldownCount, undefined, "healthy provider node has no cooldown");
|
||||
});
|
||||
|
||||
it("composes with enrichRunWithBreakers without clobbering cbState", async () => {
|
||||
const { enrichRunWithBreakers } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts");
|
||||
const run = mkRun([{ provider: "anthropic" }]);
|
||||
const withBreaker = enrichRunWithBreakers(run, {
|
||||
anthropic: { state: "OPEN", retryAfterMs: 41_000 },
|
||||
});
|
||||
const both = enrichRunWithConnectionCooldown(withBreaker, {
|
||||
anthropic: { coolingDown: 1, total: 2, soonestRetryAfterMs: 5000 },
|
||||
});
|
||||
assert.equal(both?.targets[0].cbState, "OPEN", "breaker overlay survives the cooldown overlay");
|
||||
assert.equal(both?.targets[0].cooldownCount, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* tests/unit/ui/comboFlowModel.test.ts
|
||||
*
|
||||
* TDD for `comboFlowModel` — the pure reducer/model core of Tela B.
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/comboFlowModel.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
classifyFailKind,
|
||||
reduceComboEvent,
|
||||
comboRunToFlow,
|
||||
type ComboRunModel,
|
||||
type ComboEventInput,
|
||||
} from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
import { FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles.ts";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function mkAttempt(
|
||||
targetIndex: number,
|
||||
strategy = "priority",
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "attempt",
|
||||
strategy,
|
||||
timestamp: 1000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function mkFailed(
|
||||
targetIndex: number,
|
||||
error: string,
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "failed",
|
||||
error,
|
||||
latencyMs: 100,
|
||||
timestamp: 2000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function mkSucceeded(
|
||||
targetIndex: number,
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "succeeded",
|
||||
latencyMs: 42,
|
||||
timestamp: 3000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// Full 6-event cascade:
|
||||
// attempt(0) → failed(0,"429 rate limited") → attempt(1) → failed(1,"circuit open") → attempt(2) → succeeded(2)
|
||||
function buildFullRun(): ComboRunModel {
|
||||
let run: ComboRunModel | null = null;
|
||||
run = reduceComboEvent(run, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
||||
run = reduceComboEvent(run, mkAttempt(1));
|
||||
run = reduceComboEvent(run, mkFailed(1, "circuit open"));
|
||||
run = reduceComboEvent(run, mkAttempt(2));
|
||||
run = reduceComboEvent(run, mkSucceeded(2));
|
||||
return run;
|
||||
}
|
||||
|
||||
// ── classifyFailKind ──────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyFailKind", () => {
|
||||
it("returns undefined when error is absent", () => {
|
||||
assert.equal(classifyFailKind(undefined), undefined);
|
||||
assert.equal(classifyFailKind(""), undefined);
|
||||
});
|
||||
|
||||
it("returns 'rate-limit' for 429 messages", () => {
|
||||
assert.equal(classifyFailKind("429 Too Many Requests"), "rate-limit");
|
||||
assert.equal(classifyFailKind("upstream rate limit exceeded"), "rate-limit");
|
||||
assert.equal(classifyFailKind("RATE_LIMIT_EXCEEDED"), "rate-limit");
|
||||
});
|
||||
|
||||
it("returns 'circuit-open' for circuit breaker messages", () => {
|
||||
assert.equal(classifyFailKind("circuit open"), "circuit-open");
|
||||
assert.equal(classifyFailKind("Provider circuit breaker is open"), "circuit-open");
|
||||
assert.equal(classifyFailKind("CIRCUIT_OPEN"), "circuit-open");
|
||||
});
|
||||
|
||||
it("returns 'cooldown' for cooldown messages", () => {
|
||||
assert.equal(classifyFailKind("connection cooldown active"), "cooldown");
|
||||
assert.equal(classifyFailKind("cooldown period"), "cooldown");
|
||||
});
|
||||
|
||||
it("returns 'other' for unrecognized error strings", () => {
|
||||
assert.equal(classifyFailKind("Internal server error"), "other");
|
||||
assert.equal(classifyFailKind("timeout"), "other");
|
||||
assert.equal(classifyFailKind("some unknown failure"), "other");
|
||||
});
|
||||
|
||||
it("circuit-open takes precedence over rate-limit when both match", () => {
|
||||
// edge: a message with both circuit and rate — circuit wins by regex ordering
|
||||
const result = classifyFailKind("circuit open after 429");
|
||||
assert.equal(result, "circuit-open");
|
||||
});
|
||||
});
|
||||
|
||||
// ── reduceComboEvent ──────────────────────────────────────────────────────
|
||||
|
||||
describe("reduceComboEvent — basic events", () => {
|
||||
it("creates a new run on first attempt event (null input)", () => {
|
||||
const run = reduceComboEvent(null, mkAttempt(0));
|
||||
|
||||
assert.equal(run.comboName, "test-combo");
|
||||
assert.equal(run.strategy, "priority");
|
||||
assert.equal(run.outcome, "running");
|
||||
assert.ok(run.startedAt > 0);
|
||||
assert.equal(run.finishedAt, undefined);
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[0].provider, "prov0");
|
||||
assert.equal(run.targets[0].model, "model0");
|
||||
assert.equal(run.targets[0].state, "attempting");
|
||||
});
|
||||
|
||||
it("marks target as failed and sets failKind on failed event", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
||||
|
||||
assert.equal(run.targets[0].state, "failed");
|
||||
assert.equal(run.targets[0].failKind, "rate-limit");
|
||||
assert.equal(run.targets[0].error, "429 rate limited");
|
||||
assert.equal(run.outcome, "running"); // not done yet
|
||||
});
|
||||
|
||||
it("marks target as succeeded and sets outcome + finishedAt on succeeded event", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkSucceeded(0));
|
||||
|
||||
assert.equal(run.targets[0].state, "succeeded");
|
||||
assert.equal(run.targets[0].latencyMs, 42);
|
||||
assert.equal(run.outcome, "succeeded");
|
||||
assert.ok(run.finishedAt != null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — full cascade (3 targets)", () => {
|
||||
it("produces 3 targets ordered by targetIndex", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets.length, 3);
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[1].targetIndex, 1);
|
||||
assert.equal(run.targets[2].targetIndex, 2);
|
||||
});
|
||||
|
||||
it("states are [failed, failed, succeeded]", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets[0].state, "failed");
|
||||
assert.equal(run.targets[1].state, "failed");
|
||||
assert.equal(run.targets[2].state, "succeeded");
|
||||
});
|
||||
|
||||
it("failKinds are [rate-limit, circuit-open, undefined]", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets[0].failKind, "rate-limit");
|
||||
assert.equal(run.targets[1].failKind, "circuit-open");
|
||||
assert.equal(run.targets[2].failKind, undefined);
|
||||
});
|
||||
|
||||
it("outcome is 'succeeded' and finishedAt is set", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.outcome, "succeeded");
|
||||
assert.ok(run.finishedAt != null);
|
||||
});
|
||||
|
||||
it("strategy is set from the attempt payload", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.strategy, "priority");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — ordering", () => {
|
||||
it("keeps targets sorted by targetIndex even if events arrive out of order", () => {
|
||||
// Unusual but defensive: attempt(2) then attempt(0)
|
||||
let run = reduceComboEvent(null, mkAttempt(2));
|
||||
run = reduceComboEvent(run, mkAttempt(0));
|
||||
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[1].targetIndex, 2);
|
||||
});
|
||||
|
||||
it("idempotently applies a repeated attempt for the same target", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkAttempt(0)); // duplicate
|
||||
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.targets[0].state, "attempting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — comboName key", () => {
|
||||
it("ignores events for a different comboName when run already exists", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
|
||||
// Event for a different combo — should be ignored, run returned unchanged
|
||||
const alienEvent: ComboEventInput = {
|
||||
comboName: "other-combo",
|
||||
targetIndex: 99,
|
||||
provider: "alien",
|
||||
model: "alien",
|
||||
type: "failed",
|
||||
error: "some error",
|
||||
timestamp: 9999,
|
||||
};
|
||||
run = reduceComboEvent(run, alienEvent);
|
||||
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.comboName, "test-combo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── comboRunToFlow ────────────────────────────────────────────────────────
|
||||
|
||||
describe("comboRunToFlow", () => {
|
||||
it("returns N+3 nodes for N targets (request + strategy + N targets + response)", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
// 3 targets → 1 request + 1 strategy + 3 targets + 1 response = 6 nodes
|
||||
assert.equal(nodes.length, 6, `expected 6 nodes, got ${nodes.length}`);
|
||||
});
|
||||
|
||||
it("node types: first=request, second=strategy, N middle=target, last=response", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal(nodes[0].type, "request");
|
||||
assert.equal(nodes[1].type, "strategy");
|
||||
assert.equal(nodes[2].type, "target");
|
||||
assert.equal(nodes[3].type, "target");
|
||||
assert.equal(nodes[4].type, "target");
|
||||
assert.equal(nodes[5].type, "response");
|
||||
});
|
||||
|
||||
it("target nodes carry provider, model, state, failKind in data", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
const t0 = nodes[2];
|
||||
assert.equal((t0.data as Record<string, unknown>).provider, "prov0");
|
||||
assert.equal((t0.data as Record<string, unknown>).model, "model0");
|
||||
assert.equal((t0.data as Record<string, unknown>).state, "failed");
|
||||
assert.equal((t0.data as Record<string, unknown>).failKind, "rate-limit");
|
||||
|
||||
const t2 = nodes[4];
|
||||
assert.equal((t2.data as Record<string, unknown>).state, "succeeded");
|
||||
assert.equal((t2.data as Record<string, unknown>).failKind, undefined);
|
||||
});
|
||||
|
||||
it("strategy node carries the strategy name in data", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal((nodes[1].data as Record<string, unknown>).strategy, "priority");
|
||||
assert.equal((nodes[1].data as Record<string, unknown>).targetCount, 3);
|
||||
});
|
||||
|
||||
it("edges: N+3 sequential edges (one per node-to-node link)", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// 6 nodes → 5 edges: request→strategy, strategy→t0, t0→t1, t1→t2, t2→response
|
||||
assert.equal(edges.length, 5, `expected 5 edges, got ${edges.length}`);
|
||||
});
|
||||
|
||||
it("each edge connects consecutive nodes in order", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes, edges } = comboRunToFlow(run);
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
assert.equal(edges[i].source, nodes[i].id, `edge[${i}].source mismatch`);
|
||||
assert.equal(edges[i].target, nodes[i + 1].id, `edge[${i}].target mismatch`);
|
||||
}
|
||||
});
|
||||
|
||||
it("failed edges are styled with error color", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[2] is strategy→t0 (failed)… actually edges are: [0]req→strat, [1]strat→t0, [2]t0→t1, [3]t1→t2, [4]t2→resp
|
||||
// edge for t0 (failed): index 1 (strategy→t0)
|
||||
// The edge going INTO t0 should reflect t0 state
|
||||
const t0IncomingEdge = edges[1]; // strategy→target0
|
||||
const style = t0IncomingEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null, "edge should have style");
|
||||
assert.equal(style.stroke, FLOW_EDGE_COLORS.error, "failed target edge should be error color");
|
||||
});
|
||||
|
||||
it("succeeded edge is styled with active/green color", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[4] is t2→response (t2 succeeded)
|
||||
const t2ResponseEdge = edges[4];
|
||||
const style = t2ResponseEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null);
|
||||
assert.equal(
|
||||
style.stroke,
|
||||
FLOW_EDGE_COLORS.active,
|
||||
"succeeded target edge should be active/green color"
|
||||
);
|
||||
});
|
||||
|
||||
it("idle/attempting edges are styled with idle or last-used color (not error/green)", () => {
|
||||
// A run with one target still attempting
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[1] = strategy→t0 (attempting)
|
||||
const attemptingEdge = edges[1];
|
||||
const style = attemptingEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null);
|
||||
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.error);
|
||||
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.active);
|
||||
});
|
||||
|
||||
it("produces deterministic node IDs", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal(nodes[0].id, "request");
|
||||
assert.equal(nodes[1].id, "strategy");
|
||||
assert.equal(nodes[2].id, "target-0");
|
||||
assert.equal(nodes[3].id, "target-1");
|
||||
assert.equal(nodes[4].id, "target-2");
|
||||
assert.equal(nodes[5].id, "response");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { ComboRunModel } from "@/app/(dashboard)/dashboard/combos/live/comboFlowModel";
|
||||
|
||||
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// Stub next/image to avoid Next.js internals in jsdom
|
||||
vi.mock("next/image", () => ({
|
||||
default: (props: Record<string, unknown>) =>
|
||||
React.createElement("img", { src: props.src as string, alt: props.alt as string }),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { ComboLiveStudio } = await import("@/app/(dashboard)/dashboard/combos/live/ComboLiveStudio");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample run: 2 failures then 1 success ────────────────────────────────
|
||||
|
||||
const SAMPLE_RUN: ComboRunModel = {
|
||||
comboName: "daily-cascade",
|
||||
strategy: "priority",
|
||||
targets: [
|
||||
{
|
||||
targetIndex: 0,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
state: "failed",
|
||||
latencyMs: 1200,
|
||||
error: "429 rate limit exceeded",
|
||||
failKind: "rate-limit",
|
||||
},
|
||||
{
|
||||
targetIndex: 1,
|
||||
provider: "anthropic",
|
||||
model: "claude-3-sonnet",
|
||||
state: "failed",
|
||||
latencyMs: 800,
|
||||
error: "circuit open after 5 failures",
|
||||
failKind: "circuit-open",
|
||||
},
|
||||
{
|
||||
targetIndex: 2,
|
||||
provider: "gemini",
|
||||
model: "gemini-1.5-pro",
|
||||
state: "succeeded",
|
||||
latencyMs: 620,
|
||||
},
|
||||
],
|
||||
outcome: "succeeded",
|
||||
startedAt: 1718000000000,
|
||||
finishedAt: 1718000003000,
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ComboLiveStudio", () => {
|
||||
describe("with a static run prop", () => {
|
||||
it("renders the studio wrapper", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ReactFlow canvas (.react-flow)", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the combo name in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("shows the strategy in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("priority");
|
||||
});
|
||||
|
||||
it("shows the outcome in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='run-outcome']")?.textContent).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("shows provider names from target nodes", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
const text = container.textContent ?? "";
|
||||
// Node internals rendered via ReactFlow in jsdom — at minimum the toolbar
|
||||
// shows the combo name. Provider names appear in node elements if ReactFlow
|
||||
// renders custom node interiors.
|
||||
expect(text).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("does NOT render the combo selector when a run prop is supplied", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='combo-selector']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Single/Fleet toggle buttons", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='mode-single']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='mode-fleet']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show the disconnected banner when isConnected=true", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={true} />);
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the disconnected banner when isConnected=false", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state (no run, no events)", () => {
|
||||
it("renders the empty state when run=null", () => {
|
||||
const container = mount(<ComboLiveStudio run={null} />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio-empty']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state message", () => {
|
||||
const container = mount(<ComboLiveStudio run={null} />);
|
||||
expect(container.textContent).toContain("No combo run available");
|
||||
});
|
||||
|
||||
it("shows the combo selector with no events", () => {
|
||||
const container = mount(<ComboLiveStudio comboEvents={[]} />);
|
||||
// No run prop → selector should appear
|
||||
expect(container.querySelector("[data-testid='combo-selector']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the studio wrapper even when empty", () => {
|
||||
const container = mount(<ComboLiveStudio />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fleet mode", () => {
|
||||
it("shows fleet overview after clicking Fleet toggle", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} comboEvents={[]} />);
|
||||
const fleetBtn = container.querySelector(
|
||||
"[data-testid='mode-fleet']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(fleetBtn).toBeTruthy();
|
||||
act(() => {
|
||||
fleetBtn!.click();
|
||||
});
|
||||
// Fleet panel renders (either FleetOverview or its empty state)
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
// The .react-flow canvas should no longer be visible in fleet mode
|
||||
expect(container.querySelector(".react-flow")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnected state with a run", () => {
|
||||
it("still renders the canvas when disconnected but run is provided", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
||||
// Canvas renders (graceful degrade — show last known state)
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
// Banner also shows
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("CombosError boundary", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a recoverable fallback instead of throwing", async () => {
|
||||
const { default: CombosError } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/combos/error");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const reset = vi.fn();
|
||||
const error = Object.assign(new Error("boom"), { digest: "abc123" });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CombosError error={error} reset={reset} />);
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='alert']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("Failed to load combos");
|
||||
});
|
||||
|
||||
it("calls reset() exactly once when Try Again is clicked", async () => {
|
||||
const { default: CombosError } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/combos/error");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const reset = vi.fn();
|
||||
const error = Object.assign(new Error("boom"), { digest: undefined });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CombosError error={error} reset={reset} />);
|
||||
});
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Try Again"
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
|
||||
let container: HTMLElement; let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string, init: any) => {
|
||||
if (url.includes("/compression/preview")) {
|
||||
const body = JSON.parse(init.body);
|
||||
return { ok: true, json: async () => ({ original: "user: x", compressed: `c-${body.engineId}`,
|
||||
originalTokens: 10, compressedTokens: 5, savingsPct: 50, mode: "stacked", durationMs: 1,
|
||||
engineBreakdown: [], diff: [], preservedBlocks: [], ruleRemovals: [] }) } as any;
|
||||
}
|
||||
if (url.includes("/compression/compare/verify")) {
|
||||
const body = JSON.parse(init.body);
|
||||
return { ok: true, json: async () => ({
|
||||
results: body.items.map((it: any) => ({ id: it.id, verdict: "same", usdCost: 0.001, skippedCapped: false })),
|
||||
totalUsd: 0.001 * body.items.length, capped: false }) } as any;
|
||||
}
|
||||
// /compression/compare
|
||||
return { ok: true, json: async () => ({ rows: [
|
||||
{ engine: "rtk", meanSavingsPercent: 43, meanRetention: 0.98, totalCompressedTokens: 700 },
|
||||
{ engine: "lite", meanSavingsPercent: 1, meanRetention: 1.0, totalCompressedTokens: 1230 },
|
||||
] }) } as any;
|
||||
}));
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); document.body.innerHTML = ""; vi.restoreAllMocks(); });
|
||||
|
||||
describe("CompareView", () => {
|
||||
it("renders best-first ranked rows after load", async () => {
|
||||
const { CompareView } = await import("@/app/(dashboard)/dashboard/compression/studio/CompareView");
|
||||
await act(async () => { root.render(<CompareView text="$ npm install\n..." />); });
|
||||
await act(async () => { (container.querySelector('[data-testid="compare-load"]') as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
const rows = container.querySelectorAll('[data-testid="compare-row"]');
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows[0].textContent).toContain("rtk");
|
||||
});
|
||||
|
||||
it("verify button is disabled until a judge model is entered, then renders verdicts on demand", async () => {
|
||||
const { CompareView } = await import("@/app/(dashboard)/dashboard/compression/studio/CompareView");
|
||||
await act(async () => { root.render(<CompareView text="$ npm install" />); });
|
||||
await act(async () => { (container.querySelector('[data-testid="compare-load"]') as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
const verifyBtn = container.querySelector('[data-testid="verify-all"]') as HTMLButtonElement;
|
||||
expect(verifyBtn.disabled).toBe(true); // no judge model yet
|
||||
const modelInput = container.querySelector('[data-testid="verify-model"]') as HTMLInputElement;
|
||||
await act(async () => { setInputValue(modelInput, "claude-haiku"); });
|
||||
expect((container.querySelector('[data-testid="verify-all"]') as HTMLButtonElement).disabled).toBe(false);
|
||||
await act(async () => { (container.querySelector('[data-testid="verify-all"]') as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
const verdicts = Array.from(container.querySelectorAll('[data-testid="verify-verdict"]')).map((c) => c.textContent);
|
||||
expect(verdicts.filter((t) => t === "same").length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Port of upstream decolua/9router PR #925.
|
||||
*
|
||||
* Bug: For openai-compatible / anthropic-compatible providers, the AddApiKeyModal
|
||||
* had no "Default Model" input and the saved payload omitted `defaultModel`, so the
|
||||
* created connection persisted with `defaultModel = null` and was effectively
|
||||
* unusable (no model to bind requests to). The fix adds a required Default Model
|
||||
* field for compatible providers and threads it through the save payload.
|
||||
*
|
||||
* Two TDD tests:
|
||||
* 1) Compatible provider: the "Default Model" input is rendered and its value
|
||||
* is forwarded as `defaultModel` in the onSave payload.
|
||||
* 2) Non-compatible (first-party) provider: the field is NOT rendered, and
|
||||
* `defaultModel` stays undefined in the payload (no regression for the
|
||||
* existing first-party flow).
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddApiKeyModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal"
|
||||
);
|
||||
|
||||
const DEFAULT_MODEL_INPUT_SELECTOR = 'input[data-testid="compat-default-model-input"]';
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddApiKeyModal
|
||||
isOpen
|
||||
onSave={async () => undefined}
|
||||
onClose={() => {}}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — compatible provider default-model field (PR #925)", () => {
|
||||
it("renders the Default Model input only when the provider is compatible", () => {
|
||||
const compatEl = render({
|
||||
provider: "openai-compatible:my-node",
|
||||
providerName: "My OpenAI Compatible",
|
||||
isCompatible: true,
|
||||
});
|
||||
expect(compatEl.querySelector(DEFAULT_MODEL_INPUT_SELECTOR)).toBeTruthy();
|
||||
|
||||
const nonCompatEl = render({ provider: "openai", providerName: "OpenAI" });
|
||||
expect(nonCompatEl.querySelector(DEFAULT_MODEL_INPUT_SELECTOR)).toBeNull();
|
||||
});
|
||||
|
||||
it("threads defaultModel from the form into the save payload for compatible providers", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
provider: "openai-compatible:my-node",
|
||||
providerName: "My OpenAI Compatible",
|
||||
isCompatible: true,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const defaultModelInput = el.querySelector<HTMLInputElement>(DEFAULT_MODEL_INPUT_SELECTOR)!;
|
||||
setInputValue(nameInput, "My Connection");
|
||||
setInputValue(apiKeyInput, "sk-test-key");
|
||||
setInputValue(defaultModelInput, "gpt-4o-mini");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.defaultModel).toBe("gpt-4o-mini");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Minimal i18n stub — returns key as-is so assertions are stable.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values && typeof values.count !== "undefined" && typeof values.total !== "undefined") {
|
||||
return `${values.count}/${values.total} ${key}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub fetch before importing the component.
|
||||
const fetchCalls: string[] = [];
|
||||
const mockFetch = vi.fn((url: string) => {
|
||||
fetchCalls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
headers: {
|
||||
get: (name: string) => (name === "x-total-count" ? "0" : null),
|
||||
},
|
||||
} as unknown as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Import component after mocks.
|
||||
const { default: ComplianceTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/audit/ComplianceTab"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForCondition(fn: () => boolean, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderTab() {
|
||||
// Must set IS_REACT_ACT_ENVIRONMENT before each render.
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ComplianceTab />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls.length = 0;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ComplianceTab — actor filter", { timeout: 30000 }, () => {
|
||||
it("renders the actor label, input and datalist", async () => {
|
||||
const el = renderTab();
|
||||
|
||||
// Wait until fetch resolves and the component re-renders with the filter grid.
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
// The actor label text should appear (i18n stub returns key as-is).
|
||||
expect(el.textContent).toContain("actor");
|
||||
|
||||
// An input with list="compliance-actors" must exist.
|
||||
const actorInput = el.querySelector('input[list="compliance-actors"]');
|
||||
expect(actorInput).toBeTruthy();
|
||||
|
||||
// A datalist with id="compliance-actors" must exist.
|
||||
const datalist = el.querySelector("datalist#compliance-actors");
|
||||
expect(datalist).toBeTruthy();
|
||||
});
|
||||
|
||||
it("initial fetch does not include actor param", async () => {
|
||||
renderTab();
|
||||
|
||||
// Wait until at least one fetch call is made.
|
||||
await waitForCondition(() => fetchCalls.length > 0);
|
||||
|
||||
// The first fetch should not include an actor param (state is "").
|
||||
expect(fetchCalls[0]).not.toContain("actor=");
|
||||
});
|
||||
|
||||
it("re-fetches with actor param after typing in the actor input", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
const beforeCount = fetchCalls.length;
|
||||
|
||||
const actorInput = el.querySelector(
|
||||
'input[list="compliance-actors"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(actorInput).toBeTruthy();
|
||||
|
||||
// Simulate React controlled input change via nativeInputValueSetter + dispatchEvent.
|
||||
act(() => {
|
||||
if (actorInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(actorInput, "admin");
|
||||
actorInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
actorInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for a new fetch triggered by the actor state change.
|
||||
await waitForCondition(
|
||||
() => fetchCalls.slice(beforeCount).some((url) => url.includes("actor=admin")),
|
||||
5000
|
||||
);
|
||||
|
||||
expect(fetchCalls.some((url) => url.includes("actor=admin"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clearFilters button exists and clicking it does not throw", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
// Find the clearFilters button (i18n stub returns key "clearFilters").
|
||||
const clearBtn = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("clearFilters")
|
||||
);
|
||||
expect(clearBtn).toBeTruthy();
|
||||
|
||||
// Clicking the button should not throw.
|
||||
expect(() => {
|
||||
act(() => {
|
||||
clearBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CompressionLogTab from "@/app/(dashboard)/dashboard/logs/CompressionLogTab";
|
||||
|
||||
// Keys that CompressionLogTab uses from the "logs" namespace.
|
||||
const LOGS_KEYS = ["loading", "compressionLogTitle", "compressionLogEmpty", "tokens"] as const;
|
||||
|
||||
// Track calls to useTranslations so we can assert the namespace.
|
||||
const namespacesSeen: string[] = [];
|
||||
|
||||
// i18n stub that simulates next-intl's useTranslations.
|
||||
// Only the "logs" namespace has the compression-related keys.
|
||||
const logsMessages: Record<string, string> = {
|
||||
loading: "Loading...",
|
||||
compressionLogTitle: "Compression Log",
|
||||
compressionLogEmpty:
|
||||
"No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
|
||||
tokens: "tokens",
|
||||
};
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace: string) => {
|
||||
namespacesSeen.push(namespace);
|
||||
return (key: string) => {
|
||||
if (namespace === "logs") {
|
||||
return logsMessages[key] ?? `_MISSING_${key}`;
|
||||
}
|
||||
// Any other namespace returns a sentinel so tests can catch wrong namespace.
|
||||
return `_WRONG_NS_${namespace}_${key}`;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch so the component doesn't fail on network calls.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response)
|
||||
)
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("CompressionLogTab — logs namespace", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
namespacesSeen.length = 0;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders without missing-key sentinels — all required logs.* keys are present", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// The rendered text must NOT contain any _MISSING_ or _WRONG_NS_ sentinel.
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).not.toContain("_MISSING_");
|
||||
expect(text).not.toContain("_WRONG_NS_");
|
||||
});
|
||||
|
||||
it("uses the 'logs' namespace (not 'settings')", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// Verify that useTranslations was called with "logs".
|
||||
expect(namespacesSeen).toContain("logs");
|
||||
// Must NOT have been called with "settings".
|
||||
expect(namespacesSeen).not.toContain("settings");
|
||||
});
|
||||
|
||||
it("renders loading state text from logs namespace", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// Either the loading text or the empty state text should appear — both come from logs keys.
|
||||
const text = container.textContent ?? "";
|
||||
const hasExpectedText =
|
||||
text.includes(logsMessages.loading) ||
|
||||
text.includes(logsMessages.compressionLogEmpty) ||
|
||||
text.includes(logsMessages.compressionLogTitle);
|
||||
|
||||
expect(hasExpectedText).toBe(true);
|
||||
});
|
||||
|
||||
it("all required logs keys have defined values in the mock", () => {
|
||||
// Sanity-check: the test mock itself covers all the keys the component uses.
|
||||
for (const key of LOGS_KEYS) {
|
||||
expect(logsMessages[key]).toBeDefined();
|
||||
expect(typeof logsMessages[key]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// T06 — CompressionPipelineEditor (gaps v3.8.42). The drag-reorder logic itself lives in
|
||||
// the pure `compressionPipelineModel` (covered by compression-pipeline-model.test.ts); here
|
||||
// we assert the controlled-component wiring: rendering one row per step and reporting
|
||||
// add/remove/patch edits through `onChange`.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { CompressionPipelineEditor } = await import(
|
||||
"../../../src/shared/components/compression/CompressionPipelineEditor"
|
||||
);
|
||||
|
||||
const TABLE = {
|
||||
rtk: ["standard", "aggressive"],
|
||||
caveman: ["lite", "full", "ultra"],
|
||||
} as const;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(steps: { engine: string; intensity?: string }[], onChange: (s: unknown) => void) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<CompressionPipelineEditor
|
||||
steps={steps}
|
||||
onChange={onChange}
|
||||
engineIntensities={TABLE as unknown as Record<string, readonly string[]>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompressionPipelineEditor (T06)", () => {
|
||||
it("renders one sortable row per step", () => {
|
||||
render(
|
||||
[
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
],
|
||||
() => {}
|
||||
);
|
||||
expect(container.querySelector('[data-testid="compression-pipeline-editor"]')).toBeTruthy();
|
||||
expect(container.querySelectorAll('[data-testid^="pipeline-row-"]').length).toBe(2);
|
||||
// each row exposes a drag handle
|
||||
expect(container.querySelectorAll('[data-testid^="pipeline-drag-"]').length).toBe(2);
|
||||
});
|
||||
|
||||
it("Add step appends a normalized step via onChange", () => {
|
||||
let received: { engine: string; intensity?: string }[] | null = null;
|
||||
render([{ engine: "rtk", intensity: "standard" }], (s) => {
|
||||
received = s as typeof received;
|
||||
});
|
||||
const addBtn = container.querySelector('[data-testid="pipeline-add-step"]') as HTMLButtonElement;
|
||||
act(() => addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(received).not.toBeNull();
|
||||
expect(received!.length).toBe(2);
|
||||
expect(received![1].engine).toBe("rtk");
|
||||
// appended step is normalized (valid intensity for the engine)
|
||||
expect(TABLE.rtk).toContain(received![1].intensity);
|
||||
});
|
||||
|
||||
it("Remove drops the row; the button is disabled when only one step remains", () => {
|
||||
let received: unknown[] | null = null;
|
||||
render(
|
||||
[
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
],
|
||||
(s) => {
|
||||
received = s as unknown[];
|
||||
}
|
||||
);
|
||||
const removeBtn = container.querySelector(
|
||||
'[data-testid="pipeline-remove-0"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(removeBtn.disabled).toBe(false);
|
||||
act(() => removeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(received).toEqual([{ engine: "caveman", intensity: "full" }]);
|
||||
|
||||
// single-step pipeline: remove is disabled (never below minLength 1)
|
||||
render([{ engine: "rtk", intensity: "standard" }], () => {});
|
||||
const onlyRemove = container.querySelector(
|
||||
'[data-testid="pipeline-remove-0"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(onlyRemove.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("changing the engine re-normalizes the intensity through onChange", () => {
|
||||
let received: { engine: string; intensity?: string }[] | null = null;
|
||||
render([{ engine: "rtk", intensity: "aggressive" }], (s) => {
|
||||
received = s as typeof received;
|
||||
});
|
||||
const engineSelect = container.querySelector(
|
||||
'[data-testid="pipeline-row-0"] select[aria-label="Engine"]'
|
||||
) as HTMLSelectElement;
|
||||
act(() => {
|
||||
engineSelect.value = "caveman";
|
||||
engineSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(received).not.toBeNull();
|
||||
// 'aggressive' is not a caveman intensity → coerced to the first caveman intensity
|
||||
expect(received![0].engine).toBe("caveman");
|
||||
expect(TABLE.caveman).toContain(received![0].intensity);
|
||||
expect(received![0].intensity).toBe("lite");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* tests/unit/ui/compression-replay-reducer.test.ts
|
||||
*
|
||||
* F5.1 coverage gap (F3.2): the `useCompressionReplay` state machine was untested
|
||||
* (only buildReplayFrames was covered). This pins the pure reducer + timing.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
replayReducer,
|
||||
INITIAL_STATE,
|
||||
stepMs,
|
||||
type ReplayState,
|
||||
} from "../../../src/app/(dashboard)/dashboard/compression/studio/useCompressionReplay.ts";
|
||||
|
||||
describe("replayReducer — compression replay state machine (F3.2)", () => {
|
||||
it("starts idle: frameIndex -1, not playing, speed 1", () => {
|
||||
assert.deepEqual(INITIAL_STATE, { frameIndex: -1, isPlaying: false, speed: 1 });
|
||||
});
|
||||
|
||||
it("PLAY without frameIndex starts playing and keeps the current frame", () => {
|
||||
const s = replayReducer({ frameIndex: 2, isPlaying: false, speed: 1 }, { type: "PLAY" });
|
||||
assert.equal(s.isPlaying, true);
|
||||
assert.equal(s.frameIndex, 2);
|
||||
});
|
||||
|
||||
it("PLAY with frameIndex restarts from that frame (used for replay-from-start)", () => {
|
||||
const s = replayReducer(INITIAL_STATE, { type: "PLAY", frameIndex: 0 });
|
||||
assert.equal(s.isPlaying, true);
|
||||
assert.equal(s.frameIndex, 0);
|
||||
});
|
||||
|
||||
it("PAUSE stops playing but keeps the frame", () => {
|
||||
const s = replayReducer({ frameIndex: 1, isPlaying: true, speed: 1 }, { type: "PAUSE" });
|
||||
assert.equal(s.isPlaying, false);
|
||||
assert.equal(s.frameIndex, 1);
|
||||
});
|
||||
|
||||
it("TICK advances one frame while below the last", () => {
|
||||
const start: ReplayState = { frameIndex: -1, isPlaying: true, speed: 1 };
|
||||
const a = replayReducer(start, { type: "TICK", totalFrames: 3 });
|
||||
assert.equal(a.frameIndex, 0);
|
||||
assert.equal(a.isPlaying, true);
|
||||
const b = replayReducer(a, { type: "TICK", totalFrames: 3 });
|
||||
assert.equal(b.frameIndex, 1);
|
||||
assert.equal(b.isPlaying, true);
|
||||
});
|
||||
|
||||
it("TICK stops at the last frame and clears isPlaying (auto-stop at end)", () => {
|
||||
const atSecondLast: ReplayState = { frameIndex: 1, isPlaying: true, speed: 1 };
|
||||
const done = replayReducer(atSecondLast, { type: "TICK", totalFrames: 3 });
|
||||
assert.equal(done.frameIndex, 2, "lands on the last frame index (totalFrames-1)");
|
||||
assert.equal(done.isPlaying, false, "playback stops once the last frame is reached");
|
||||
});
|
||||
|
||||
it("RESET returns to idle (-1, not playing) but preserves speed", () => {
|
||||
const s = replayReducer({ frameIndex: 2, isPlaying: true, speed: 3 }, { type: "RESET" });
|
||||
assert.equal(s.frameIndex, -1);
|
||||
assert.equal(s.isPlaying, false);
|
||||
assert.equal(s.speed, 3, "speed must survive a reset");
|
||||
});
|
||||
|
||||
it("SET_SPEED changes speed without touching frame/play state", () => {
|
||||
const s = replayReducer({ frameIndex: 1, isPlaying: true, speed: 1 }, {
|
||||
type: "SET_SPEED",
|
||||
speed: 3,
|
||||
});
|
||||
assert.equal(s.speed, 3);
|
||||
assert.equal(s.frameIndex, 1);
|
||||
assert.equal(s.isPlaying, true);
|
||||
});
|
||||
|
||||
it("unknown action is a no-op (returns the same state reference)", () => {
|
||||
const state: ReplayState = { frameIndex: 0, isPlaying: false, speed: 1 };
|
||||
// @ts-expect-error — exercising the default branch with an invalid action
|
||||
assert.equal(replayReducer(state, { type: "NOPE" }), state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepMs — frame pacing by speed (F3.2)", () => {
|
||||
it("maps speed to a shorter interval as speed rises", () => {
|
||||
assert.equal(stepMs(1), 400);
|
||||
assert.equal(stepMs(3), 133); // round(400/3)
|
||||
assert.equal(stepMs(0.3), 1333); // round(400/0.3)
|
||||
assert.ok(stepMs(3) < stepMs(1) && stepMs(1) < stepMs(0.3), "monotonic: faster = shorter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* F5.2 finding (HIGH): changing replay speed mid-play was silently ignored — `startTick`
|
||||
* read `speedRef.current` synchronously but the ref was only synced in a post-render
|
||||
* effect, so the new interval kept the OLD cadence. This drives the hook with fake timers
|
||||
* to prove a mid-play speed change actually takes effect.
|
||||
*/
|
||||
import React, { act, useMemo, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { useCompressionReplay } from "@/app/(dashboard)/dashboard/compression/studio/useCompressionReplay";
|
||||
import { compressionEventToModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
import type {
|
||||
CompressionCompletedPayload,
|
||||
} from "@/lib/events/types";
|
||||
|
||||
// 3 engine steps → buildReplayFrames yields 3 frames (indices 0,1,2).
|
||||
const PAYLOAD: CompressionCompletedPayload = {
|
||||
requestId: "r1",
|
||||
comboId: "c",
|
||||
mode: "stacked",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 700,
|
||||
savingsPercent: 30,
|
||||
engineBreakdown: [
|
||||
{ engine: "a", originalTokens: 1000, compressedTokens: 900, savingsPercent: 10, techniquesUsed: [], durationMs: 1 },
|
||||
{ engine: "b", originalTokens: 900, compressedTokens: 800, savingsPercent: 11, techniquesUsed: [], durationMs: 1 },
|
||||
{ engine: "c", originalTokens: 800, compressedTokens: 700, savingsPercent: 12, techniquesUsed: [], durationMs: 1 },
|
||||
],
|
||||
timestamp: 1718000000000,
|
||||
};
|
||||
|
||||
let api: ReturnType<typeof useCompressionReplay> | null = null;
|
||||
function Harness() {
|
||||
// Memoize so the model identity is stable across re-renders (otherwise the
|
||||
// model-change effect would RESET on every render).
|
||||
const model = useMemo(() => compressionEventToModel(PAYLOAD), []);
|
||||
const hook = useCompressionReplay(model);
|
||||
// Capture in an effect (not during render) so we don't reassign a module-scoped
|
||||
// variable mid-render. The effect runs after each commit, so `api` tracks the latest.
|
||||
useEffect(() => {
|
||||
api = hook;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
function mount(): void {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
api = null;
|
||||
});
|
||||
|
||||
describe("useCompressionReplay — mid-play speed change (F5.2)", () => {
|
||||
it("applies a new speed to the running ticker (was silently ignored before the fix)", () => {
|
||||
mount();
|
||||
expect(api!.totalFrames).toBe(3);
|
||||
|
||||
// Play at speed 1 (interval 400ms): one tick advances to frame 0.
|
||||
act(() => {
|
||||
api!.play();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
expect(api!.frameIndex).toBe(0);
|
||||
|
||||
// Speed up to 3 (interval ~133ms) WHILE playing, then advance only 133ms.
|
||||
// With the fix the ticker restarts at the faster cadence and advances;
|
||||
// without it the interval stayed at 400ms and 133ms would not tick.
|
||||
act(() => {
|
||||
api!.setSpeed(3);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(133);
|
||||
});
|
||||
expect(api!.frameIndex).toBe(1);
|
||||
expect(api!.speed).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Mock next-intl (CompressionSettingsTab calls useTranslations) ──────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mountInContainer(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Mock fetch ────────────────────────────────────────────────────────────
|
||||
// CompressionSettingsTab fetches:
|
||||
// GET /api/settings/compression → returns CompressionConfig (or null)
|
||||
// GET /api/compression/rules → returns { rules: RuleMetadata[] }
|
||||
|
||||
function setupFetchMock() {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
// D0 tile reads this; it must resolve to a valid Summary BEFORE the generic
|
||||
// /api/settings/compression match (the run-telemetry URL contains that prefix).
|
||||
if (url.includes("/run-telemetry")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
totalRuns: 0,
|
||||
totalTokensSaved: 0,
|
||||
runsWithStyles: 0,
|
||||
bypassCount: 0,
|
||||
totalOutputTokens: 0,
|
||||
appliedStyleCounts: {},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
enabled: false,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/compression/rules")) {
|
||||
return new Response(JSON.stringify({ rules: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionSettingsPage", () => {
|
||||
it("mounts without throwing and renders content", { timeout: 15000 }, async () => {
|
||||
setupFetchMock();
|
||||
const { default: CompressionSettingsPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/settings/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionSettingsPage />);
|
||||
});
|
||||
|
||||
// Flush any pending microtasks from effects
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
// D0: the read-only telemetry tile is mounted alongside the panel.
|
||||
expect(container.querySelector('[data-testid="compression-styles-tile"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not crash when fetch calls fail (fail-soft)", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { default: CompressionSettingsPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/settings/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionSettingsPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Component should still be mounted (not crashed)
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.parentNode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// next-intl → echo the key so we can assert on stable identifiers.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const CONFIG = {
|
||||
enabled: true,
|
||||
defaultMode: "standard",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
cavemanConfig: {
|
||||
enabled: true,
|
||||
compressRoles: ["user"],
|
||||
skipRules: [],
|
||||
minMessageLength: 50,
|
||||
preservePatterns: [],
|
||||
intensity: "full",
|
||||
},
|
||||
cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true },
|
||||
rtkConfig: { enabled: true, intensity: "standard" },
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/settings/compression")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(CONFIG) });
|
||||
}
|
||||
if (u.includes("/api/compression/rules")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ rules: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: false, json: () => Promise.resolve(null) });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderTab() {
|
||||
const { default: CompressionSettingsTab } =
|
||||
await import("@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab");
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(<CompressionSettingsTab />);
|
||||
});
|
||||
// Drain the on-mount fetch → json → setState microtask chain.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompressionSettingsTab — compression controls consolidation (T11)", () => {
|
||||
it("renders the read-only TokenSaver summary that links to the unified panel", async () => {
|
||||
await renderTab();
|
||||
const hrefs = Array.from(container.querySelectorAll("a")).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("/dashboard/context/settings");
|
||||
expect(container.textContent).toContain("tokenSaverTitle");
|
||||
});
|
||||
|
||||
it("does not render a duplicate caveman engine on/off toggle (panel owns on/off)", async () => {
|
||||
await renderTab();
|
||||
const note = container.querySelector('[data-testid="caveman-panel-note"]');
|
||||
expect(note).not.toBeNull();
|
||||
// The note points users to the single-source panel...
|
||||
expect(note?.textContent).toContain("/dashboard/context/settings");
|
||||
// ...and the caveman header no longer carries its own enable toggle button.
|
||||
expect(note?.querySelector("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the advanced caveman tuning the panel does not expose", async () => {
|
||||
await renderTab();
|
||||
expect(container.textContent).toContain("compressionRoleUser");
|
||||
expect(container.textContent).toContain("compressionSkipRules");
|
||||
expect(container.textContent).toContain("compressionPreservePatterns");
|
||||
});
|
||||
|
||||
it("renders the preserveSystemPrompt 3-way mode select reflecting the shim (T05/C5)", async () => {
|
||||
await renderTab();
|
||||
const select = container.querySelector<HTMLSelectElement>(
|
||||
'[data-testid="preserve-system-mode-select"]'
|
||||
);
|
||||
expect(select).not.toBeNull();
|
||||
const values = Array.from(select!.querySelectorAll("option")).map((o) => o.value);
|
||||
expect(values).toEqual(["always", "whenNoCache", "never"]);
|
||||
// CONFIG has preserveSystemPrompt: true and no explicit mode → shim renders "always".
|
||||
expect(select!.value).toBe("always");
|
||||
});
|
||||
|
||||
it("saves the chosen mode via PUT (T05/C5)", async () => {
|
||||
await renderTab();
|
||||
const select = container.querySelector<HTMLSelectElement>(
|
||||
'[data-testid="preserve-system-mode-select"]'
|
||||
);
|
||||
await act(async () => {
|
||||
select!.value = "never";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const putCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
([, init]) => (init as RequestInit | undefined)?.method === "PUT"
|
||||
);
|
||||
expect(putCall).toBeTruthy();
|
||||
const body = JSON.parse(String((putCall![1] as RequestInit).body));
|
||||
expect(body.preserveSystemPromptMode).toBe("never");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { CompressionAnnotation } from "@/app/(dashboard)/dashboard/compression/studio/CompressionAnnotation";
|
||||
import type { CompressionStats } from "@omniroute/open-sse/services/compression/types";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
act(() => {
|
||||
createRoot(container).render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function makeStats(overrides: Partial<CompressionStats> = {}): CompressionStats {
|
||||
return {
|
||||
originalTokens: 847,
|
||||
compressedTokens: 312,
|
||||
savingsPercent: 63.16,
|
||||
techniquesUsed: ["caveman"],
|
||||
mode: "standard",
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CompressionAnnotation", () => {
|
||||
it("renders token range badge", () => {
|
||||
const stats = makeStats({
|
||||
rulesApplied: ["filler", "filler", "dedup"],
|
||||
});
|
||||
render(<CompressionAnnotation stats={stats} />);
|
||||
expect(container.textContent).toContain("847→312");
|
||||
});
|
||||
|
||||
it("renders rule pills", () => {
|
||||
const stats = makeStats({
|
||||
rulesApplied: ["filler", "filler", "dedup"],
|
||||
});
|
||||
render(<CompressionAnnotation stats={stats} />);
|
||||
expect(container.textContent).toContain("filler×2");
|
||||
expect(container.textContent).toContain("dedup×1");
|
||||
});
|
||||
|
||||
it("returns null when no rules applied", () => {
|
||||
const stats = makeStats({ rulesApplied: [], techniquesUsed: [] });
|
||||
render(<CompressionAnnotation stats={stats} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when rulesApplied is absent", () => {
|
||||
const stats = makeStats({ rulesApplied: undefined, techniquesUsed: [] });
|
||||
render(<CompressionAnnotation stats={stats} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { CompressionRunModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
|
||||
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { CompressionCockpit } =
|
||||
await import("@/app/(dashboard)/dashboard/compression/studio/CompressionCockpit");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
function click(el: Element | null): void {
|
||||
act(() => {
|
||||
el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample run ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RUN: CompressionRunModel = {
|
||||
requestId: "test-req-001",
|
||||
comboId: "daily-cascade",
|
||||
mode: "stacked",
|
||||
originalTokens: 12480,
|
||||
compressedTokens: 6090,
|
||||
savingsPercent: 51.2,
|
||||
timestamp: 1718000000000,
|
||||
steps: [
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 12480,
|
||||
compressedTokens: 9734,
|
||||
savingsPercent: 22.0,
|
||||
techniquesUsed: ["strip-comments", "shell-filter"],
|
||||
durationMs: 1.8,
|
||||
},
|
||||
{
|
||||
engine: "headroom",
|
||||
originalTokens: 9734,
|
||||
compressedTokens: 8524,
|
||||
savingsPercent: 12.4,
|
||||
techniquesUsed: ["smartcrusher"],
|
||||
durationMs: 0.9,
|
||||
},
|
||||
{
|
||||
engine: "caveman",
|
||||
originalTokens: 8524,
|
||||
compressedTokens: 6896,
|
||||
savingsPercent: 19.1,
|
||||
techniquesUsed: ["pt-BR", "filler-drop"],
|
||||
durationMs: 0.6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionCockpit", () => {
|
||||
it("renders the cockpit wrapper when a run is provided", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='compression-cockpit']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ReactFlow canvas", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the mode in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("stacked");
|
||||
});
|
||||
|
||||
it("shows the comboId in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("shows the total savings percentage in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("51.2");
|
||||
});
|
||||
|
||||
it("shows engine names (from node labels via ReactFlow)", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
// The engine names appear in the header text even if ReactFlow node internals
|
||||
// don't render in jsdom — we also check the header region.
|
||||
const text = container.textContent ?? "";
|
||||
// At minimum the run metadata renders correctly
|
||||
expect(text).toContain("test-req-001");
|
||||
});
|
||||
|
||||
it("renders the empty state when no run is given", () => {
|
||||
const container = mount(<CompressionCockpit />);
|
||||
expect(container.querySelector("[data-testid='compression-cockpit-empty']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("No compression run available");
|
||||
});
|
||||
|
||||
it("renders replay controls when a run is provided", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
// Replay button present
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Replay");
|
||||
});
|
||||
|
||||
it("offers a Canvas/Waterfall view toggle", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='cockpit-view-canvas']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='cockpit-view-waterfall']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to the waterfall inspector and back to the canvas", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
// Default view is the ReactFlow canvas, waterfall hidden.
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='waterfall-inspector']")).toBeFalsy();
|
||||
|
||||
// Switch to the waterfall (A1) view — the previously-orphan component is now reachable.
|
||||
click(container.querySelector("[data-testid='cockpit-view-waterfall']"));
|
||||
expect(container.querySelector("[data-testid='waterfall-inspector']")).toBeTruthy();
|
||||
expect(container.querySelector(".react-flow")).toBeFalsy();
|
||||
expect(
|
||||
container.querySelector("[data-testid='waterfall-total-savings']")?.textContent
|
||||
).toContain("51.2");
|
||||
|
||||
// Switch back to the canvas.
|
||||
click(container.querySelector("[data-testid='cockpit-view-canvas']"));
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='waterfall-inspector']")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
compressionEventToModel,
|
||||
compressionRunToFlow,
|
||||
buildReplayFrames,
|
||||
type CompressionRunModel,
|
||||
} from "../../../src/app/(dashboard)/dashboard/compression/studio/compressionFlowModel.ts";
|
||||
import type { CompressionCompletedPayload } from "../../../src/lib/events/types.ts";
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────
|
||||
|
||||
const THREE_ENGINE_PAYLOAD: CompressionCompletedPayload = {
|
||||
requestId: "abc123",
|
||||
comboId: "my-combo",
|
||||
mode: "stacked",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 40,
|
||||
engineBreakdown: [
|
||||
{
|
||||
engine: "lite",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 900,
|
||||
savingsPercent: 10,
|
||||
techniquesUsed: ["whitespace"],
|
||||
durationMs: 2,
|
||||
},
|
||||
{
|
||||
engine: "caveman",
|
||||
originalTokens: 900,
|
||||
compressedTokens: 750,
|
||||
savingsPercent: 16.7,
|
||||
techniquesUsed: ["filler", "dedup"],
|
||||
durationMs: 5,
|
||||
},
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 750,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 20,
|
||||
techniquesUsed: ["tool-output-trim"],
|
||||
rulesApplied: ["max-lines"],
|
||||
durationMs: 8,
|
||||
},
|
||||
],
|
||||
timestamp: 1718000000000,
|
||||
};
|
||||
|
||||
// ── compressionEventToModel ───────────────────────────────────────────────
|
||||
|
||||
describe("compressionEventToModel", () => {
|
||||
it("builds the model from a 3-engine payload", () => {
|
||||
const model: CompressionRunModel = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
|
||||
assert.equal(model.requestId, "abc123");
|
||||
assert.equal(model.comboId, "my-combo");
|
||||
assert.equal(model.mode, "stacked");
|
||||
assert.equal(model.originalTokens, 1000);
|
||||
assert.equal(model.compressedTokens, 600);
|
||||
assert.equal(model.savingsPercent, 40);
|
||||
assert.equal(model.steps.length, 3);
|
||||
});
|
||||
|
||||
it("maps each engine step correctly", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
|
||||
assert.equal(model.steps[0].engine, "lite");
|
||||
assert.equal(model.steps[0].originalTokens, 1000);
|
||||
assert.equal(model.steps[0].compressedTokens, 900);
|
||||
assert.equal(model.steps[0].savingsPercent, 10);
|
||||
assert.deepEqual(model.steps[0].techniquesUsed, ["whitespace"]);
|
||||
assert.equal(model.steps[0].durationMs, 2);
|
||||
|
||||
assert.equal(model.steps[2].engine, "rtk");
|
||||
assert.deepEqual(model.steps[2].rulesApplied, ["max-lines"]);
|
||||
});
|
||||
|
||||
it("handles null comboId gracefully", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, comboId: null };
|
||||
const model = compressionEventToModel(payload);
|
||||
assert.equal(model.comboId, null);
|
||||
});
|
||||
|
||||
it("handles empty engineBreakdown", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
assert.equal(model.steps.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── compressionRunToFlow ──────────────────────────────────────────────────
|
||||
|
||||
describe("compressionRunToFlow", () => {
|
||||
it("returns N+2 nodes for N engine steps (input + N + output)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// 3 engine steps → 1 input + 3 engine + 1 output = 5 nodes
|
||||
assert.equal(flow.nodes.length, 5, `expected 5 nodes, got ${flow.nodes.length}`);
|
||||
});
|
||||
|
||||
it("node types: first=input, last=output, middle=engine", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
assert.equal(flow.nodes[0].type, "input");
|
||||
assert.equal(flow.nodes[flow.nodes.length - 1].type, "output");
|
||||
assert.equal(flow.nodes[1].type, "engine");
|
||||
assert.equal(flow.nodes[2].type, "engine");
|
||||
assert.equal(flow.nodes[3].type, "engine");
|
||||
});
|
||||
|
||||
it("edges connect nodes in sequence (N+1 edges for N+2 nodes)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// 5 nodes → 4 sequential edges
|
||||
assert.equal(flow.edges.length, 4, `expected 4 edges, got ${flow.edges.length}`);
|
||||
});
|
||||
|
||||
it("each edge source/target connects consecutive nodes", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
for (let i = 0; i < flow.edges.length; i++) {
|
||||
assert.equal(
|
||||
flow.edges[i].source,
|
||||
flow.nodes[i].id,
|
||||
`edge ${i} source should be node[${i}].id`
|
||||
);
|
||||
assert.equal(
|
||||
flow.edges[i].target,
|
||||
flow.nodes[i + 1].id,
|
||||
`edge ${i} target should be node[${i + 1}].id`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("engine nodes carry per-step data", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// nodes[1] is the first engine step (lite)
|
||||
const liteNode = flow.nodes[1];
|
||||
assert.equal((liteNode.data as Record<string, unknown>).engine, "lite");
|
||||
assert.equal((liteNode.data as Record<string, unknown>).originalTokens, 1000);
|
||||
assert.equal((liteNode.data as Record<string, unknown>).compressedTokens, 900);
|
||||
assert.equal((liteNode.data as Record<string, unknown>).savingsPercent, 10);
|
||||
});
|
||||
|
||||
it("handles zero-engine model (only input + output)", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
assert.equal(flow.nodes.length, 2);
|
||||
assert.equal(flow.edges.length, 1);
|
||||
assert.equal(flow.nodes[0].type, "input");
|
||||
assert.equal(flow.nodes[1].type, "output");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildReplayFrames ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildReplayFrames", () => {
|
||||
it("returns progressive snapshots (1 engine applied, 2 applied, ...)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// 3 engines → 3 frames
|
||||
assert.equal(frames.length, 3, `expected 3 frames, got ${frames.length}`);
|
||||
});
|
||||
|
||||
it("frame[0] has 1 step, frame[1] has 2 steps, frame[2] has 3 steps", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
assert.equal(frames[0].steps.length, 1);
|
||||
assert.equal(frames[1].steps.length, 2);
|
||||
assert.equal(frames[2].steps.length, 3);
|
||||
});
|
||||
|
||||
it("frames are independent objects (mutations don't bleed)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// Mutating frame[0].steps should not affect frame[1]
|
||||
frames[0].steps.push({ engine: "fake" } as (typeof frames)[0]["steps"][0]);
|
||||
assert.equal(frames[1].steps.length, 2);
|
||||
});
|
||||
|
||||
it("each frame carries the correct compressedTokens up to that step", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// Frame 0: after lite — compressedTokens = 900
|
||||
assert.equal(frames[0].compressedTokens, 900);
|
||||
// Frame 1: after caveman — compressedTokens = 750
|
||||
assert.equal(frames[1].compressedTokens, 750);
|
||||
// Frame 2: after rtk — compressedTokens = 600
|
||||
assert.equal(frames[2].compressedTokens, 600);
|
||||
});
|
||||
|
||||
it("returns empty array for model with no steps", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
const frames = buildReplayFrames(model);
|
||||
assert.equal(frames.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedPut {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function setupFetchMock(): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
const initialConfig = {
|
||||
enabled: true,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
activeComboId: null,
|
||||
contextEditing: { enabled: false },
|
||||
};
|
||||
const combos = [{ id: "c1", name: "RTK only", pipeline: [{ engine: "rtk" }] }];
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (url.includes("/api/context/combos/default")) return json({}, 404);
|
||||
if (url.includes("/api/context/combos")) return json({ combos });
|
||||
if (url.includes("/api/compression/engines")) return json({ engines: [] });
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (method === "PUT") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
puts.push({ url, body });
|
||||
return json({ ...initialConfig, ...body });
|
||||
}
|
||||
return json(initialConfig);
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
return { puts };
|
||||
}
|
||||
|
||||
function setSelectValue(select: HTMLSelectElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")!.set!;
|
||||
setter.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("CompressionHub — active-profile selector", () => {
|
||||
async function render() {
|
||||
const { default: CompressionHub } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders the active-profile select with Default + each named combo", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
expect(container.textContent).toContain("Default (from panel)");
|
||||
expect(container.textContent).toContain("RTK only");
|
||||
});
|
||||
|
||||
it("changing the select to a combo PUTs activeComboId === that id", async () => {
|
||||
const { puts } = setupFetchMock();
|
||||
const container = await render();
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
setSelectValue(select, "c1");
|
||||
});
|
||||
await flush();
|
||||
const settingsPuts = puts.filter((p) => p.url.includes("/api/settings/compression"));
|
||||
expect(settingsPuts.length).toBeGreaterThan(0);
|
||||
expect(settingsPuts.pop()!.body.activeComboId).toBe("c1");
|
||||
});
|
||||
|
||||
it("preview shows the Default fallback initially, and the combo engines once a combo is active", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
const preview = () => container.querySelector('[data-testid="active-profile-preview"]');
|
||||
expect(preview()).toBeTruthy();
|
||||
expect(preview()!.textContent).toContain("Default");
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
setSelectValue(select, "c1");
|
||||
});
|
||||
await flush();
|
||||
expect(preview()!.textContent).toContain("rtk");
|
||||
});
|
||||
|
||||
it("no longer renders the master Token Saver toggle, the mode selector, or reorder buttons", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.querySelector('[aria-label="Toggle Token Saver"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Move up"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Move down"]')).toBeNull();
|
||||
// The Aggressive mode button's hint text is gone with the mode selector.
|
||||
expect(container.textContent).not.toContain("Summary plus aging");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mountInContainer(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Mock fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINES = [{ id: "rtk", name: "RTK", stackPriority: 10, stable: true }];
|
||||
|
||||
function enginePayload() {
|
||||
return {
|
||||
engines: ENGINES.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
description: `${e.name} description`,
|
||||
icon: "compress",
|
||||
stackable: true,
|
||||
stackPriority: e.stackPriority,
|
||||
metadata: { stable: e.stable },
|
||||
configSchema: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function setupFetchMock(opts: { contextEditingEnabled?: boolean }): FetchCall[] {
|
||||
const { contextEditingEnabled = false } = opts;
|
||||
const calls: FetchCall[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
let parsedBody: unknown;
|
||||
if (typeof init?.body === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(init.body);
|
||||
} catch {
|
||||
parsedBody = init.body;
|
||||
}
|
||||
}
|
||||
calls.push({ url, method, body: parsedBody });
|
||||
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return json({
|
||||
enabled: true,
|
||||
defaultMode: "stacked",
|
||||
contextEditing: { enabled: contextEditingEnabled },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return json(enginePayload());
|
||||
}
|
||||
if (url.includes("/api/context/combos/default")) {
|
||||
return json({ id: "default-caveman", name: "Standard Savings", pipeline: [] });
|
||||
}
|
||||
if (url.includes("/api/context/combos")) {
|
||||
return json({ combos: [] });
|
||||
}
|
||||
if (url.includes("/api/combos")) {
|
||||
return json({ combos: [] });
|
||||
}
|
||||
if (url.includes("/api/compression/language-packs")) {
|
||||
return json({ packs: [] });
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionHub — Context Editing", () => {
|
||||
it("renders the delegated-compression section with the Context Editing toggle", async () => {
|
||||
setupFetchMock({ contextEditingEnabled: false });
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Compressão delegada ao provedor");
|
||||
expect(text).toContain("Context Editing (Claude)");
|
||||
});
|
||||
|
||||
it("renders the Claude-only delegated note", async () => {
|
||||
setupFetchMock({ contextEditingEnabled: false });
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("apenas para Claude");
|
||||
expect(text).toContain("não reescrevemos a mensagem");
|
||||
});
|
||||
|
||||
it("PUTs contextEditing: { enabled: true } when the toggle is flipped on", async () => {
|
||||
const calls = setupFetchMock({ contextEditingEnabled: false });
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const toggle = container.querySelector(
|
||||
'button[role="switch"][aria-label="Context Editing"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const put = calls.find(
|
||||
(c) => c.method === "PUT" && c.url.includes("/api/settings/compression")
|
||||
);
|
||||
expect(put).toBeTruthy();
|
||||
expect((put?.body as { contextEditing?: { enabled?: boolean } })?.contextEditing).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mountInContainer(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Mock fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINES = [
|
||||
{ id: "session-dedup", name: "Session Dedup", stackPriority: 3, stable: true },
|
||||
{ id: "rtk", name: "RTK", stackPriority: 10, stable: true },
|
||||
{ id: "caveman", name: "Caveman", stackPriority: 20, stable: true },
|
||||
{ id: "llmlingua", name: "LLMLingua-2", stackPriority: 35, stable: false },
|
||||
];
|
||||
|
||||
function enginePayload() {
|
||||
return {
|
||||
engines: ENGINES.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
description: `${e.name} description`,
|
||||
icon: "compress",
|
||||
stackable: true,
|
||||
stackPriority: e.stackPriority,
|
||||
metadata: { stable: e.stable },
|
||||
configSchema: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function setupFetchMock(opts: {
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
pipeline?: Array<{ engine: string }>;
|
||||
}) {
|
||||
const { enabled = true, mode = "stacked", pipeline = [{ engine: "rtk" }] } = opts;
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return json({ enabled, defaultMode: mode });
|
||||
}
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return json(enginePayload());
|
||||
}
|
||||
if (url.includes("/api/context/combos/default")) {
|
||||
return json({ id: "default-caveman", name: "Standard Savings", pipeline });
|
||||
}
|
||||
if (url.includes("/api/context/combos")) {
|
||||
return json({ combos: [] });
|
||||
}
|
||||
if (url.includes("/api/combos")) {
|
||||
return json({ combos: [] });
|
||||
}
|
||||
if (url.includes("/api/compression/language-packs")) {
|
||||
return json({ packs: [] });
|
||||
}
|
||||
return json({}, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionHub", () => {
|
||||
it("renders the master switch, mode selector, and the layered pipeline", async () => {
|
||||
setupFetchMock({ enabled: true, mode: "stacked", pipeline: [{ engine: "rtk" }] });
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Compression Hub");
|
||||
expect(text).toContain("Token Saver");
|
||||
expect(text).toContain("Stacked");
|
||||
// Active pipeline engine (from the default combo) renders
|
||||
expect(text).toContain("RTK");
|
||||
// Inactive engines from the catalog render too
|
||||
expect(text).toContain("Caveman");
|
||||
// Active-pipeline callout shows when enabled && stacked
|
||||
expect(text).toContain("Layer pipeline is active");
|
||||
});
|
||||
|
||||
it("INVARIANT #1: no per-layer control issues a PUT/POST to /api/context/combos/default", { timeout: 20000 }, async () => {
|
||||
const comboWrites: { method: string }[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url.includes("/api/context/combos/default")) {
|
||||
if (init?.method === "PUT" || init?.method === "POST") {
|
||||
comboWrites.push({ method: init.method });
|
||||
}
|
||||
return json({ id: "default", name: "Default", pipeline: [{ engine: "rtk" }] });
|
||||
}
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return json({ enabled: true, defaultMode: "stacked" });
|
||||
}
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return json(enginePayload());
|
||||
}
|
||||
if (url.includes("/api/context/combos") || url.includes("/api/combos")) {
|
||||
return json({ combos: [] });
|
||||
}
|
||||
if (url.includes("/api/compression/language-packs")) {
|
||||
return json({ packs: [] });
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Click every on/off switch in the Hub (master + any layer controls that remain).
|
||||
const switches = Array.from(container.querySelectorAll('[role="switch"]'));
|
||||
for (const sw of switches) {
|
||||
await act(async () => {
|
||||
(sw as HTMLElement).click();
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
expect(comboWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows the activation warning when Token Saver is off", async () => {
|
||||
setupFetchMock({ enabled: false, mode: "off", pipeline: [] });
|
||||
const { default: CompressionHub } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Enable Token Saver");
|
||||
expect(text).toContain("only run in Stacked mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressionCombosPageClient", () => {
|
||||
it("renders the Hub on top and the named-combos manager below", async () => {
|
||||
setupFetchMock({ enabled: true, mode: "stacked", pipeline: [{ engine: "rtk" }] });
|
||||
const { default: CompressionCombosPageClient } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CompressionCombosPageClient />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Compression Hub");
|
||||
expect(text).toContain("Named combos");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
ENGINE_IDS,
|
||||
engineMeta,
|
||||
} from "../../../open-sse/services/compression/engineCatalog.ts";
|
||||
|
||||
// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo
|
||||
// the key. This test therefore asserts ONLY on i18n-independent strings: catalog
|
||||
// labels/descriptions, engine ids, data-testid hooks, and the PUT request body.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
// ── Harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fetch stub ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CapturedPut {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function setupFetchMock(): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const initialConfig = {
|
||||
enabled: true,
|
||||
defaultMode: "stacked",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
engines: {
|
||||
rtk: { enabled: true, level: "standard" },
|
||||
caveman: { enabled: false },
|
||||
},
|
||||
activeComboId: null,
|
||||
cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true },
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
|
||||
if (url.includes("/api/settings/compression/mcp-accessibility")) {
|
||||
if (method === "PUT") {
|
||||
puts.push({ url, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return json({ enabled: true });
|
||||
}
|
||||
return json({ enabled: true, maxTextChars: 50000 });
|
||||
}
|
||||
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (method === "PUT") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
puts.push({ url, body });
|
||||
// Echo a merged config so the panel keeps a coherent state.
|
||||
return json({ ...initialConfig, ...body });
|
||||
}
|
||||
return json(initialConfig);
|
||||
}
|
||||
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
|
||||
return { puts };
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionPanel", () => {
|
||||
it("renders a row for every engine id in the catalog", async () => {
|
||||
setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
for (const id of ENGINE_IDS) {
|
||||
const row = container.querySelector(`[data-testid="engine-row-${id}"]`);
|
||||
expect(row, `expected a row for engine "${id}"`).toBeTruthy();
|
||||
// Catalog label/description are hardcoded English (i18n-independent).
|
||||
expect(container.textContent).toContain(engineMeta(id).label);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the rtk level 'standard' as selected", async () => {
|
||||
setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const select = container.querySelector(
|
||||
`[data-testid="engine-row-rtk"] select`
|
||||
) as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
expect(select?.value).toBe("standard");
|
||||
});
|
||||
|
||||
it("toggling caveman PUTs engines.caveman.enabled === true", async () => {
|
||||
const { puts } = setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
// The data-testid hook wraps the Toggle; its inner <button role="switch"> is the
|
||||
// clickable element.
|
||||
const toggle = container.querySelector(
|
||||
`[data-testid="engine-toggle-caveman"] button`
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggle, "caveman toggle must exist").toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
toggle!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const settingsPuts = puts.filter(
|
||||
(p) => p.url.includes("/api/settings/compression") && !p.url.includes("mcp-accessibility")
|
||||
);
|
||||
expect(settingsPuts.length).toBeGreaterThan(0);
|
||||
const lastEngines = settingsPuts
|
||||
.map((p) => p.body.engines as Record<string, { enabled: boolean }> | undefined)
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastEngines).toBeTruthy();
|
||||
expect(lastEngines!.caveman.enabled).toBe(true);
|
||||
// Full engines map is sent (whole-row persistence), so rtk is not dropped.
|
||||
expect(lastEngines!.rtk.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("derived-pipeline preview reflects the enabled engines", async () => {
|
||||
setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const preview = container.querySelector(`[data-testid="derived-pipeline-preview"]`);
|
||||
expect(preview).toBeTruthy();
|
||||
// Only rtk is enabled in the initial config → preview mentions rtk, not caveman.
|
||||
expect(preview?.textContent).toContain("rtk");
|
||||
expect(preview?.textContent).not.toContain("caveman");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
OUTPUT_STYLE_IDS,
|
||||
outputStyleMeta,
|
||||
} from "../../../open-sse/services/compression/outputStyles/catalog.ts";
|
||||
|
||||
// Locale is mutable per-test so we can exercise the locale gate (terse-cjk → zh only).
|
||||
const intl = vi.hoisted(() => ({ locale: "en" }));
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => intl.locale,
|
||||
}));
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => root.render(ui));
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
intl.locale = "en";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function setupFetchMock() {
|
||||
const puts: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const initial = {
|
||||
enabled: true,
|
||||
autoTriggerTokens: 0,
|
||||
preserveSystemPrompt: true,
|
||||
engines: {},
|
||||
activeComboId: null,
|
||||
outputStyles: [],
|
||||
cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true },
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (url.includes("/api/settings/compression/mcp-accessibility")) return json({ enabled: true });
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (method === "PUT") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
puts.push({ url, body });
|
||||
return json({ ...initial, ...body });
|
||||
}
|
||||
return json(initial);
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
return { puts };
|
||||
}
|
||||
|
||||
describe("CompressionPanel output styles", () => {
|
||||
it("renders one row per catalog style", async () => {
|
||||
setupFetchMock();
|
||||
intl.locale = "zh-CN"; // a locale that matches every gated style, so all rows render
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
const row = container.querySelector(`[data-testid="output-style-row-${id}"]`);
|
||||
expect(row, `expected a row for style "${id}"`).toBeTruthy();
|
||||
expect(container.textContent).toContain(outputStyleMeta(id).label);
|
||||
}
|
||||
});
|
||||
|
||||
it("locale-gates terse-cjk: hidden under a non-zh locale", async () => {
|
||||
setupFetchMock();
|
||||
intl.locale = "en";
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
// terse-cjk (locale "zh") must NOT be offered under "en"…
|
||||
expect(container.querySelector(`[data-testid="output-style-row-terse-cjk"]`)).toBeFalsy();
|
||||
// …while the non-gated styles still render.
|
||||
expect(container.querySelector(`[data-testid="output-style-row-terse-prose"]`)).toBeTruthy();
|
||||
expect(container.querySelector(`[data-testid="output-style-row-less-code"]`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("locale-gates terse-cjk: offered under a zh locale (zh-CN base matches)", async () => {
|
||||
setupFetchMock();
|
||||
intl.locale = "zh-CN";
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
expect(container.querySelector(`[data-testid="output-style-row-terse-cjk"]`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggling a style PUTs an outputStyles selection", async () => {
|
||||
const { puts } = setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
const toggle = container.querySelector(
|
||||
`[data-testid="output-style-toggle-terse-prose"] button, [data-testid="output-style-toggle-terse-prose"] input`
|
||||
) as HTMLElement | null;
|
||||
expect(toggle).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggle!.click();
|
||||
});
|
||||
await flush();
|
||||
const put = puts.find((p) => "outputStyles" in p.body);
|
||||
expect(put, "a PUT carrying outputStyles").toBeTruthy();
|
||||
expect(
|
||||
(put!.body.outputStyles as Array<{ id: string }>).some((s) => s.id === "terse-prose")
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => root.render(ui));
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompressionStylesTile", () => {
|
||||
it("renders total savings and applied style ids from the summary endpoint", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
totalRuns: 4,
|
||||
totalTokensSaved: 1234,
|
||||
runsWithStyles: 3,
|
||||
bypassCount: 1,
|
||||
totalOutputTokens: 900,
|
||||
appliedStyleCounts: { "terse-prose": 3, "less-code": 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
const { default: CompressionStylesTile } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/CompressionStylesTile"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionStylesTile />);
|
||||
});
|
||||
await flush();
|
||||
expect(container.textContent).toContain("1234");
|
||||
expect(container.textContent).toContain("terse-prose");
|
||||
expect(container.textContent).toContain("less-code");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo
|
||||
// the key. This test asserts ONLY on i18n-independent hooks (data-testid + values)
|
||||
// and the captured PUT body.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => "en",
|
||||
}));
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => root.render(ui));
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedPut {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function setupFetchMock(overrides?: Record<string, unknown>): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const initial = {
|
||||
enabled: true,
|
||||
autoTriggerTokens: 0,
|
||||
preserveSystemPrompt: true,
|
||||
engines: {},
|
||||
activeComboId: null,
|
||||
outputStyles: [],
|
||||
cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true },
|
||||
ultraEngine: "heuristic",
|
||||
ultraSlmPrewarm: false,
|
||||
...overrides,
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (url.includes("/api/settings/compression/mcp-accessibility")) return json({ enabled: true });
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (method === "PUT") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
puts.push({ url, body });
|
||||
return json({ ...initial, ...body });
|
||||
}
|
||||
return json(initial);
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
return { puts };
|
||||
}
|
||||
|
||||
describe("CompressionPanel ultra SLM tier", () => {
|
||||
it("renders the ultra-engine select defaulting to heuristic", async () => {
|
||||
setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const select = container.querySelector(
|
||||
`[data-testid="ultra-engine-select"]`
|
||||
) as HTMLSelectElement | null;
|
||||
expect(select, "ultra-engine select must render").toBeTruthy();
|
||||
expect(select?.value).toBe("heuristic");
|
||||
// The pre-warm toggle is hidden while heuristic is selected.
|
||||
expect(
|
||||
container.querySelector(`[data-testid="ultra-slm-prewarm-toggle"]`)
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("selecting SLM PUTs ultraEngine:'slm' and reveals the pre-warm toggle", async () => {
|
||||
const { puts } = setupFetchMock();
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const select = container.querySelector(
|
||||
`[data-testid="ultra-engine-select"]`
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
await act(async () => {
|
||||
select.value = "slm";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const put = puts.find((p) => "ultraEngine" in p.body);
|
||||
expect(put, "a PUT carrying ultraEngine").toBeTruthy();
|
||||
expect(put!.body.ultraEngine).toBe("slm");
|
||||
|
||||
// Pre-warm toggle now visible.
|
||||
const toggle = container.querySelector(`[data-testid="ultra-slm-prewarm-toggle"]`);
|
||||
expect(toggle, "pre-warm toggle must appear when slm is selected").toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggling pre-warm PUTs ultraSlmPrewarm:true when SLM is active", async () => {
|
||||
const { puts } = setupFetchMock({ ultraEngine: "slm", ultraSlmPrewarm: false });
|
||||
const { default: CompressionPanel } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionPanel />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const toggle = container.querySelector(
|
||||
`[data-testid="ultra-slm-prewarm-toggle"] button, [data-testid="ultra-slm-prewarm-toggle"] input`
|
||||
) as HTMLElement | null;
|
||||
expect(toggle, "pre-warm toggle must exist when slm preselected").toBeTruthy();
|
||||
await act(async () => {
|
||||
toggle!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const put = puts.find((p) => "ultraSlmPrewarm" in p.body);
|
||||
expect(put, "a PUT carrying ultraSlmPrewarm").toBeTruthy();
|
||||
expect(put!.body.ultraSlmPrewarm).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Regression guard for #5836 — the red "Token Expired" connection badge must
|
||||
* NOT flash for OAuth refresh-capable providers (Antigravity/Gemini) whose
|
||||
* access token merely lapsed but is auto-refreshed. It should render ONLY when
|
||||
* the connection is terminally expired (testStatus === "expired").
|
||||
* Continuation of #5326.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import ConnectionRow, {
|
||||
type ConnectionRowConnection,
|
||||
} from "@/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
isOAuth: true,
|
||||
isFirst: true,
|
||||
isLast: true,
|
||||
onMoveUp: () => {},
|
||||
onMoveDown: () => {},
|
||||
onToggleActive: () => {},
|
||||
onToggleRateLimit: () => {},
|
||||
onRetest: () => {},
|
||||
onEdit: () => {},
|
||||
onDelete: () => {},
|
||||
};
|
||||
|
||||
function renderRow(connection: ConnectionRowConnection): HTMLElement {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
cleanupCallbacks.push(() => act(() => root.unmount()));
|
||||
act(() => {
|
||||
root.render(React.createElement(ConnectionRow, { ...baseProps, connection } as never));
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
const PAST = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1h ago
|
||||
|
||||
describe("ConnectionRow token expiry badge (#5836)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length) cleanupCallbacks.pop()!();
|
||||
});
|
||||
|
||||
it("does NOT render the red Token Expired badge for a healthy OAuth connection whose access token merely lapsed", () => {
|
||||
const container = renderRow({
|
||||
id: "c1",
|
||||
provider: "antigravity",
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
tokenExpiresAt: PAST,
|
||||
priority: 1,
|
||||
} as ConnectionRowConnection);
|
||||
expect(container.textContent).not.toContain("tokenExpiredBadge");
|
||||
});
|
||||
|
||||
it("renders the red Token Expired badge when the connection is terminally expired", () => {
|
||||
const container = renderRow({
|
||||
id: "c2",
|
||||
provider: "antigravity",
|
||||
testStatus: "expired",
|
||||
isActive: true,
|
||||
tokenExpiresAt: PAST,
|
||||
errorCode: "no_refresh_token",
|
||||
priority: 1,
|
||||
} as ConnectionRowConnection);
|
||||
expect(container.textContent).toContain("tokenExpiredBadge");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Tests for ConversationTab separators — CONTEXT HISTORY / MODEL RESPONSE
|
||||
* Validates the rendering logic: both sections appear when both request/response have turns;
|
||||
* only CONTEXT HISTORY appears when response is empty.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
|
||||
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
|
||||
|
||||
function makeRequest(overrides: Partial<InterceptedRequest> = {}): InterceptedRequest {
|
||||
return {
|
||||
id: "test-id",
|
||||
source: "agent-bridge",
|
||||
timestamp: new Date().toISOString(),
|
||||
method: "POST",
|
||||
host: "api.openai.com",
|
||||
path: "/v1/chat/completions",
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestBody: null,
|
||||
requestSize: 0,
|
||||
responseHeaders: {},
|
||||
responseBody: null,
|
||||
responseSize: 0,
|
||||
status: 200,
|
||||
detectedKind: "llm",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConversationTab separators rendering logic", () => {
|
||||
it("shows CONTEXT HISTORY section when request has turns", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Hello!" },
|
||||
],
|
||||
});
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
assert.ok(result.request.length > 0, "request section should have turns");
|
||||
// Context History separator should be rendered (guarded by request.length > 0)
|
||||
const shouldRenderContextHistory = result.request.length > 0;
|
||||
assert.equal(shouldRenderContextHistory, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows MODEL RESPONSE section when response has turns", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
});
|
||||
const resBody = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Hello! How can I help?" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 8 },
|
||||
});
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
// Model Response separator should be rendered (guarded by response.length > 0)
|
||||
const shouldRenderModelResponse = result.response.length > 0;
|
||||
assert.equal(shouldRenderModelResponse, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT render MODEL RESPONSE when response is empty", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
});
|
||||
// No response body
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
// Response section should be empty, so MODEL RESPONSE separator should NOT render
|
||||
const shouldRenderModelResponse = result.response.length > 0;
|
||||
assert.equal(shouldRenderModelResponse, false);
|
||||
// But context history should still render
|
||||
const shouldRenderContextHistory = result.request.length > 0;
|
||||
assert.equal(shouldRenderContextHistory, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders both separators when both request and response have turns", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
});
|
||||
const resBody = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Hello!" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
});
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
const contextHistoryVisible = result.request.length > 0;
|
||||
const modelResponseVisible = result.response.length > 0;
|
||||
assert.equal(contextHistoryVisible, true, "CONTEXT HISTORY should be visible");
|
||||
assert.equal(modelResponseVisible, true, "MODEL RESPONSE should be visible");
|
||||
}
|
||||
});
|
||||
|
||||
it("request and response turns are keyed separately (req-N vs res-N)", () => {
|
||||
// Keys used: `req-${i}` for request turns, `res-${i}` for response turns
|
||||
const reqKeys = ["req-0", "req-1", "req-2"];
|
||||
const resKeys = ["res-0", "res-1"];
|
||||
|
||||
// Verify no overlap
|
||||
const allKeys = [...reqKeys, ...resKeys];
|
||||
const uniqueKeys = new Set(allKeys);
|
||||
assert.equal(uniqueKeys.size, allKeys.length, "all keys should be unique");
|
||||
});
|
||||
|
||||
it("allTurns still accounts for correct total across both sections", () => {
|
||||
const request = [
|
||||
{ role: "user" as const, content: "Hi", contentType: "text" as const },
|
||||
];
|
||||
const response = [
|
||||
{ role: "assistant" as const, content: "Hello!", contentType: "text" as const },
|
||||
];
|
||||
// Before: allTurns = [...request, ...response]
|
||||
// After: both rendered in separate sections
|
||||
const totalTurns = request.length + response.length;
|
||||
assert.equal(totalTurns, 2);
|
||||
});
|
||||
|
||||
it("conversationNotAvailable key resolves when body is null (normalizeConversation returns null)", () => {
|
||||
// When requestBody is null and responseBody is null, normalizeConversation returns null.
|
||||
// The ConversationTab renders t("conversationNotAvailable") in that case.
|
||||
const req = makeRequest({ requestBody: null, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
// Must return null so the component falls through to the conversationNotAvailable branch.
|
||||
assert.equal(result, null, "normalizeConversation must return null for non-LLM / null body");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for ConversationTab — normalizeConversation + chat bubble rendering logic
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
|
||||
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
|
||||
|
||||
function makeRequest(overrides: Partial<InterceptedRequest> = {}): InterceptedRequest {
|
||||
return {
|
||||
id: "test-id",
|
||||
source: "agent-bridge",
|
||||
timestamp: new Date().toISOString(),
|
||||
method: "POST",
|
||||
host: "api.openai.com",
|
||||
path: "/v1/chat/completions",
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestBody: null,
|
||||
requestSize: 0,
|
||||
responseHeaders: {},
|
||||
responseBody: null,
|
||||
responseSize: 0,
|
||||
status: 200,
|
||||
detectedKind: "llm",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConversationTab normalizeConversation", () => {
|
||||
it("returns null for non-LLM request", () => {
|
||||
const req = makeRequest({ detectedKind: "app", requestBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("returns null for request without body", () => {
|
||||
const req = makeRequest({ requestBody: null, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("normalizes OpenAI chat request with user message", () => {
|
||||
const body = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello!" },
|
||||
],
|
||||
});
|
||||
const req = makeRequest({ requestBody: body, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
// Should not return null for a valid LLM request
|
||||
if (result !== null) {
|
||||
assert.ok(Array.isArray(result.request), "request should be an array");
|
||||
assert.ok(result.request.length >= 1, "should have at least 1 turn");
|
||||
const roles = result.request.map((t) => t.role);
|
||||
assert.ok(roles.includes("user") || roles.includes("system"), "should have user or system role");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes OpenAI response with assistant message", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
});
|
||||
const resBody = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! How can I help?",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 8 },
|
||||
});
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
// Response turns should include assistant
|
||||
const responseTurns = result.response;
|
||||
assert.ok(Array.isArray(responseTurns));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns NormalizedConversation shape with request/response/contextKey", () => {
|
||||
const body = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
});
|
||||
const req = makeRequest({ requestBody: body });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
assert.ok("request" in result, "should have request field");
|
||||
assert.ok("response" in result, "should have response field");
|
||||
assert.ok("contextKey" in result, "should have contextKey field");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatBubble role mapping", () => {
|
||||
it("maps expected roles", () => {
|
||||
const validRoles = ["system", "user", "assistant", "tool"] as const;
|
||||
const roleLabels: Record<(typeof validRoles)[number], string> = {
|
||||
system: "System",
|
||||
user: "User",
|
||||
assistant: "Assistant",
|
||||
tool: "Tool",
|
||||
};
|
||||
|
||||
for (const role of validRoles) {
|
||||
assert.ok(roleLabels[role], `Role ${role} should have a label`);
|
||||
}
|
||||
});
|
||||
|
||||
it("system role is collapsed by default", () => {
|
||||
// System messages start collapsed per UX spec
|
||||
const defaultCollapsed = true;
|
||||
assert.equal(defaultCollapsed, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
|
||||
import {
|
||||
__resetDashboardCsrfTokenForTests,
|
||||
installDashboardCsrfFetch,
|
||||
prefetchDashboardCsrfToken,
|
||||
} from "@/shared/utils/dashboardCsrf";
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let uninstall: (() => void) | null = null;
|
||||
|
||||
function csrfResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: "csrf-token",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetDashboardCsrfTokenForTests();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
uninstall = installDashboardCsrfFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
__resetDashboardCsrfTokenForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("installDashboardCsrfFetch", () => {
|
||||
it("adds dashboard CSRF to same-origin unsafe requests", async () => {
|
||||
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/auth/csrf",
|
||||
expect.objectContaining({ cache: "no-store", credentials: "same-origin" })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/settings",
|
||||
expect.objectContaining({ method: "PATCH" })
|
||||
);
|
||||
|
||||
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
|
||||
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("adds dashboard CSRF to explicit top-level management routes", async () => {
|
||||
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
|
||||
|
||||
await fetch("/a2a", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/csrf");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("/a2a");
|
||||
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
|
||||
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
|
||||
});
|
||||
|
||||
it("reuses a prefetched dashboard CSRF token for later unsafe requests", async () => {
|
||||
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
|
||||
|
||||
await prefetchDashboardCsrfToken();
|
||||
await fetch("/api/settings", { method: "PATCH", body: "{}" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/csrf");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("/api/settings");
|
||||
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
|
||||
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
|
||||
});
|
||||
|
||||
it("does not modify safe, cross-origin, or already-protected requests", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("{}"));
|
||||
|
||||
await fetch("/api/settings");
|
||||
await fetch("https://api.example.test/api/settings", { method: "PATCH" });
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { [DASHBOARD_CSRF_HEADER]: "existing-token" },
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
"/api/settings",
|
||||
"https://api.example.test/api/settings",
|
||||
"/api/settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not attach dashboard CSRF to client API aliases or public API routes", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("{}"));
|
||||
|
||||
await fetch("/api/v1/rerank", { method: "POST" });
|
||||
await fetch("/v1/chat/completions", { method: "POST" });
|
||||
await fetch("/v1beta/models", { method: "POST" });
|
||||
await fetch("/responses", { method: "POST" });
|
||||
await fetch("/codex", { method: "POST" });
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
"/api/v1/rerank",
|
||||
"/v1/chat/completions",
|
||||
"/v1beta/models",
|
||||
"/responses",
|
||||
"/codex",
|
||||
"/api/auth/logout",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { DiffPane } from "@/app/(dashboard)/dashboard/compression/studio/DiffPane";
|
||||
let container: HTMLElement; let root: Root;
|
||||
beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); });
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); document.body.innerHTML = ""; });
|
||||
describe("DiffPane", () => {
|
||||
it("renders removed and same segments with distinct testids", () => {
|
||||
act(() => { root.render(<DiffPane segments={[{ type: "same", text: "keep this " }, { type: "removed", text: "drop this" }]} preservedBlocks={[]} />); });
|
||||
expect(container.querySelector('[data-testid="diff-removed"]')?.textContent).toContain("drop this");
|
||||
expect(container.querySelector('[data-testid="diff-same"]')?.textContent).toContain("keep this");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { edgeStyle, FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles.ts";
|
||||
|
||||
describe("flow edgeStyles (U0 — extracted from ProviderTopology)", () => {
|
||||
it("exposes the shared flow palette", () => {
|
||||
assert.equal(FLOW_EDGE_COLORS.active, "#22c55e");
|
||||
assert.equal(FLOW_EDGE_COLORS.error, "#ef4444");
|
||||
assert.equal(FLOW_EDGE_COLORS.last, "#f59e0b");
|
||||
assert.equal(FLOW_EDGE_COLORS.idle, "var(--color-border)");
|
||||
});
|
||||
|
||||
it("styles an error edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, false, true), {
|
||||
stroke: "#ef4444",
|
||||
strokeWidth: 2,
|
||||
opacity: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles an active edge", () => {
|
||||
assert.deepEqual(edgeStyle(true, false, false), {
|
||||
stroke: "#22c55e",
|
||||
strokeWidth: 2.5,
|
||||
opacity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles a last-used edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, true, false), {
|
||||
stroke: "#f59e0b",
|
||||
strokeWidth: 1.5,
|
||||
opacity: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles an idle edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, false, false), {
|
||||
stroke: "var(--color-border)",
|
||||
strokeWidth: 1,
|
||||
opacity: 0.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies precedence error > active > last", () => {
|
||||
assert.equal(edgeStyle(true, true, true).stroke, "#ef4444"); // error wins
|
||||
assert.equal(edgeStyle(true, true, false).stroke, "#22c55e"); // active beats last
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ id: "openrouter" }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: EditConnectionModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal");
|
||||
|
||||
const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]';
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<EditConnectionModal
|
||||
isOpen
|
||||
providerId={(props.providerId as string) || "openrouter"}
|
||||
onSave={async () => undefined}
|
||||
onClose={() => {}}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
clear: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("EditConnectionModal — import only free models", () => {
|
||||
it("shows the free-only toggle for a connection whose provider has free models", () => {
|
||||
const el = render({
|
||||
connection: { id: "conn-1", provider: "openrouter", providerSpecificData: {} },
|
||||
});
|
||||
expect(el.querySelector(FREE_TOGGLE)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the free-only toggle for a provider without free models", () => {
|
||||
const el = render({
|
||||
providerId: "anthropic",
|
||||
connection: { id: "conn-2", provider: "anthropic", providerSpecificData: {} },
|
||||
});
|
||||
expect(el.querySelector(FREE_TOGGLE)).toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the persisted flag as initially checked", () => {
|
||||
const el = render({
|
||||
connection: {
|
||||
id: "conn-3",
|
||||
provider: "openrouter",
|
||||
providerSpecificData: { importFreeModelsOnly: true },
|
||||
},
|
||||
});
|
||||
expect(el.querySelector(FREE_TOGGLE)!.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("clears the flag (explicit false) and re-syncs when toggled off", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onResyncModels = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
connection: {
|
||||
id: "conn-5",
|
||||
provider: "openrouter",
|
||||
name: "OR",
|
||||
providerSpecificData: { importFreeModelsOnly: true },
|
||||
},
|
||||
onSave,
|
||||
onResyncModels,
|
||||
});
|
||||
|
||||
const toggle = el.querySelector<HTMLButtonElement>(FREE_TOGGLE)!;
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
act(() => {
|
||||
toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
// Must be an explicit `false`, not `undefined` — otherwise the PUT merge keeps the old `true`.
|
||||
expect(onSave.mock.calls[0][0].providerSpecificData?.importFreeModelsOnly).toBe(false);
|
||||
await waitFor(() => onResyncModels.mock.calls.length > 0);
|
||||
expect(onResyncModels).toHaveBeenCalledWith("conn-5");
|
||||
});
|
||||
|
||||
it("saves the flag and triggers a re-sync when toggled on", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onResyncModels = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
connection: { id: "conn-4", provider: "openrouter", name: "OR", providerSpecificData: {} },
|
||||
onSave,
|
||||
onResyncModels,
|
||||
});
|
||||
|
||||
const toggle = el.querySelector<HTMLButtonElement>(FREE_TOGGLE)!;
|
||||
act(() => {
|
||||
toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.importFreeModelsOnly).toBe(true);
|
||||
|
||||
await waitFor(() => onResyncModels.mock.calls.length > 0);
|
||||
expect(onResyncModels).toHaveBeenCalledWith("conn-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditConnectionModal — quota scraping fields", () => {
|
||||
it("saves OpenCode Go workspace and replacement auth cookie", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "opencode-go",
|
||||
connection: {
|
||||
id: "conn-opencode-go",
|
||||
provider: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
authType: "apikey",
|
||||
providerSpecificData: { workspaceId: "workspace-existing" },
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
|
||||
const workspaceInput = el.querySelector<HTMLInputElement>(
|
||||
'input[name="opencodeGoWorkspaceId"]'
|
||||
)!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="opencodeGoAuthCookie"]')!;
|
||||
expect(workspaceInput.value).toBe("workspace-existing");
|
||||
setInputValue(workspaceInput, "workspace-updated");
|
||||
setInputValue(cookieInput, "auth=opencode-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated");
|
||||
expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie");
|
||||
});
|
||||
|
||||
it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "ollama-cloud",
|
||||
connection: {
|
||||
id: "conn-ollama-cloud",
|
||||
provider: "ollama-cloud",
|
||||
name: "Ollama Cloud",
|
||||
authType: "apikey",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
|
||||
expect(el.querySelector<HTMLInputElement>('input[name="ollamaCloudUsageCookie"]')).toBeTruthy();
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect("ollamaCloudUsageCookie" in payload.providerSpecificData).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Modal: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "modal", "data-title": title },
|
||||
React.createElement("button", { onClick: onClose, "data-testid": "modal-close" }, "X"),
|
||||
children,
|
||||
footer,
|
||||
)
|
||||
: null,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
"data-testid": testId,
|
||||
variant,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
"data-testid"?: string;
|
||||
variant?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick,
|
||||
disabled: disabled || loading,
|
||||
"data-testid": testId,
|
||||
"data-variant": variant,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}),
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
className?: string;
|
||||
}) => React.createElement("select", { value, onChange, className }, children),
|
||||
}));
|
||||
|
||||
const MOCK_MEMORY = {
|
||||
id: "mem-1",
|
||||
type: "factual" as const,
|
||||
key: "user.name",
|
||||
content: "Alice",
|
||||
metadata: { source: "test" },
|
||||
};
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EditMemoryModal", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen=false", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='modal']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders modal with memory fields populated when isOpen=true", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='modal']")).toBeTruthy();
|
||||
// The key input should have value "user.name" and content textarea "Alice"
|
||||
const inputs = Array.from(container.querySelectorAll("input"));
|
||||
const keyInput = inputs.find((i) => i.value === "user.name");
|
||||
expect(keyInput).toBeTruthy();
|
||||
const textareas = Array.from(container.querySelectorAll("textarea"));
|
||||
const contentTextarea = textareas.find((ta) => ta.value === "Alice");
|
||||
expect(contentTextarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows metadata JSON in textarea", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const textareas = container.querySelectorAll("textarea");
|
||||
// Should have at least the content textarea and metadata textarea
|
||||
expect(textareas.length).toBeGreaterThanOrEqual(2);
|
||||
// The metadata textarea should contain the JSON
|
||||
const metadataTextarea = Array.from(textareas).find((ta) =>
|
||||
ta.value.includes('"source"'),
|
||||
);
|
||||
expect(metadataTextarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls PUT /api/memory/[id] and invokes onSaved+onClose when save succeeds", async () => {
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// Find and click Save button (text="save")
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
expect(saveBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
saveBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0].includes("mem-1") &&
|
||||
c[1] &&
|
||||
c[1].method === "PUT",
|
||||
);
|
||||
expect(putCalls.length).toBeGreaterThan(0);
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error message when PUT fails", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: { message: "update failed" } }),
|
||||
});
|
||||
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
await act(async () => {
|
||||
saveBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("update failed");
|
||||
});
|
||||
|
||||
it("shows metadata validation error for invalid JSON", async () => {
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const textareas = Array.from(container.querySelectorAll("textarea"));
|
||||
// Find the metadata textarea (the one with JSON content)
|
||||
const metadataTextarea = textareas.find((ta) => ta.value.includes('"source"'));
|
||||
expect(metadataTextarea).toBeTruthy();
|
||||
|
||||
// Use nativeInputValueSetter to set value and fire change event
|
||||
await act(async () => {
|
||||
if (metadataTextarea) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(metadataTextarea, "not valid json {{{");
|
||||
metadataTextarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
// The error text should contain the i18n key
|
||||
expect(container.textContent).toContain("editModal.metadataInvalid");
|
||||
});
|
||||
|
||||
it("calls onClose when modal close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { default: EditMemoryModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditMemoryModal
|
||||
memory={MOCK_MEMORY}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const closeBtn = container.querySelector("[data-testid='modal-close']") as HTMLButtonElement | null;
|
||||
expect(closeBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
closeBtn?.click();
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EmbeddingSourceSelector", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultSettings = {
|
||||
embeddingSource: "auto" as const,
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
};
|
||||
|
||||
it("renders all 4 source options", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='embedding-source-auto']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-remote']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-static']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='embedding-source-transformers']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only providers with hasKey=true in remote dropdown", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const providers = [
|
||||
{
|
||||
provider: "openai",
|
||||
hasKey: true,
|
||||
models: [
|
||||
{
|
||||
id: "openai/text-embedding-3-small",
|
||||
name: "text-embedding-3-small",
|
||||
dimensions: 1536,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "cohere",
|
||||
hasKey: false,
|
||||
models: [{ id: "cohere/embed-english-v3", name: "embed-english", dimensions: 1024 }],
|
||||
},
|
||||
];
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "remote" }}
|
||||
providers={providers}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
// openai should be visible (hasKey=true)
|
||||
expect(container.textContent).toContain("text-embedding-3-small");
|
||||
// cohere should NOT be visible (hasKey=false)
|
||||
expect(container.textContent).not.toContain("embed-english");
|
||||
});
|
||||
|
||||
it("shows no-provider warning when remote selected but no providers with key", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "remote" }}
|
||||
providers={[{ provider: "cohere", hasKey: false, models: [] }]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("embedding.noRemoteProviders");
|
||||
});
|
||||
|
||||
it("shows transformers warning when transformers source selected", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={{ ...defaultSettings, embeddingSource: "transformers" }}
|
||||
providers={[]}
|
||||
onSave={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("embedding.transformersWarning");
|
||||
});
|
||||
|
||||
it("toggle-static-enabled calls onSave", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-static-enabled']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ staticEnabled: true });
|
||||
});
|
||||
|
||||
it("toggle-transformers-enabled calls onSave", async () => {
|
||||
const { default: EmbeddingSourceSelector } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector"
|
||||
);
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmbeddingSourceSelector
|
||||
settings={defaultSettings}
|
||||
providers={[]}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-transformers-enabled']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ transformersEnabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mountInContainer(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Mock fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEnginePayload(engines: Array<{ id: string; name: string }>) {
|
||||
return {
|
||||
engines: engines.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
description: "",
|
||||
icon: "table_rows",
|
||||
stackable: true,
|
||||
stackPriority: 15,
|
||||
metadata: {},
|
||||
configSchema: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const ANALYTICS_PAYLOAD = {
|
||||
runs: 0,
|
||||
tokensSaved: 0,
|
||||
avgSavingsPercent: 0,
|
||||
days: 7,
|
||||
};
|
||||
|
||||
function setupFetchMock(engines: Array<{ id: string; name: string }>) {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return new Response(JSON.stringify(makeEnginePayload(engines)), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/context/combos")) {
|
||||
return new Response(JSON.stringify({ pipeline: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/context/analytics/engine")) {
|
||||
return new Response(JSON.stringify(ANALYTICS_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HeadroomPage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "headroom", name: "Headroom" }]);
|
||||
const { default: HeadroomPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/headroom/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<HeadroomPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("Headroom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionDedupPage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "session-dedup", name: "Session Dedup" }]);
|
||||
const { default: SessionDedupPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/session-dedup/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<SessionDedupPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("Session Dedup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CcrPage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "ccr", name: "CCR" }]);
|
||||
const { default: CcrPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/ccr/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<CcrPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("CCR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LlmlinguaPage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "llmlingua", name: "LLMLingua" }]);
|
||||
const { default: LlmlinguaPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/llmlingua/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<LlmlinguaPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("LLMLingua");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LitePage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "lite", name: "Lite" }]);
|
||||
const { default: LitePage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/lite/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<LitePage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("Lite");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AggressivePage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "aggressive", name: "Aggressive" }]);
|
||||
const { default: AggressivePage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/aggressive/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<AggressivePage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("Aggressive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UltraPage", () => {
|
||||
it("mounts without throwing and renders the engine name", async () => {
|
||||
setupFetchMock([{ id: "ultra", name: "Ultra" }]);
|
||||
const { default: UltraPage } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/context/ultra/page");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<UltraPage />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.textContent).toContain("Ultra");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
"data-testid": testId,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
"data-testid"?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ onClick, disabled: disabled || loading, "data-testid": testId, "data-variant": variant },
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the hooks directly to avoid swr dependency resolution issues
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus",
|
||||
() => ({
|
||||
useEngineStatus: () => ({
|
||||
status: {
|
||||
keyword: { available: true, backend: "FTS5" },
|
||||
embedding: {
|
||||
source: "remote",
|
||||
model: "openai/text-embedding-3-small",
|
||||
dimensions: 1536,
|
||||
available: true,
|
||||
reason: "provider openai with key configured",
|
||||
cacheStats: { hits: 0, misses: 0, size: 0 },
|
||||
},
|
||||
vectorStore: {
|
||||
backend: "sqlite-vec",
|
||||
available: true,
|
||||
rowCount: 10,
|
||||
needsReindex: 0,
|
||||
reason: "sqlite-vec loaded",
|
||||
},
|
||||
qdrant: { enabled: false, healthy: null, latencyMs: null, error: null },
|
||||
rerank: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
model: null,
|
||||
available: false,
|
||||
reason: "rerank disabled",
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockSave = vi.fn().mockResolvedValue(true);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings",
|
||||
() => ({
|
||||
useMemorySettings: () => ({
|
||||
settings: {
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
vectorStore: "auto",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
mutate: vi.fn(),
|
||||
save: mockSave,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/MemoryEngineStatus",
|
||||
() => ({
|
||||
default: ({ status }: { status: { embedding: { available: boolean } } }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "engine-status-panel" },
|
||||
status.embedding.available ? "embedding:available" : "embedding:unavailable",
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector",
|
||||
() => ({
|
||||
default: ({
|
||||
onSave,
|
||||
}: {
|
||||
settings: unknown;
|
||||
providers: unknown[];
|
||||
onSave: (u: unknown) => Promise<boolean>;
|
||||
saving?: boolean;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "embedding-selector" },
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "toggle-transformers-btn",
|
||||
onClick: () => onSave({ transformersEnabled: true }),
|
||||
},
|
||||
"toggle-transformers",
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "qdrant-config-card" }, "QdrantCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/RerankConfigCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "rerank-config-card" }, "RerankCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("EngineTab", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ providers: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the engine status panel", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='engine-status-panel']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("embedding:available");
|
||||
});
|
||||
|
||||
it("renders QdrantConfigCard and RerankConfigCard", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='qdrant-config-card']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='rerank-config-card']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Reindex Now button", async () => {
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='reindex-now-button']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls save() when EmbeddingSourceSelector calls onSave", async () => {
|
||||
mockSave.mockClear();
|
||||
|
||||
const { default: EngineTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<EngineTab />);
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='toggle-transformers-btn']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggleBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockSave).toHaveBeenCalledWith({ transformersEnabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
description: "Enable this engine",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: "maxTokens",
|
||||
type: "number",
|
||||
label: "Max Tokens",
|
||||
defaultValue: 1000,
|
||||
min: 100,
|
||||
max: 8000,
|
||||
},
|
||||
{
|
||||
key: "prefix",
|
||||
type: "string",
|
||||
label: "Prefix",
|
||||
defaultValue: "",
|
||||
},
|
||||
{
|
||||
key: "strategy",
|
||||
type: "select",
|
||||
label: "Strategy",
|
||||
defaultValue: "fast",
|
||||
options: [
|
||||
{ value: "fast", label: "Fast" },
|
||||
{ value: "thorough", label: "Thorough" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "techniques",
|
||||
type: "multiselect",
|
||||
label: "Techniques",
|
||||
defaultValue: [],
|
||||
options: [
|
||||
{ value: "strip-comments", label: "Strip Comments" },
|
||||
{ value: "minify", label: "Minify" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_VALUE: Record<string, unknown> = {
|
||||
enabled: false,
|
||||
maxTokens: 1000,
|
||||
prefix: "hello",
|
||||
strategy: "fast",
|
||||
techniques: [],
|
||||
};
|
||||
|
||||
describe("EngineConfigForm", () => {
|
||||
it("renders a checkbox for the boolean field", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
// Label text appears
|
||||
expect(container.textContent).toContain("Enabled");
|
||||
// Description appears
|
||||
expect(container.textContent).toContain("Enable this engine");
|
||||
// Checkbox exists
|
||||
const checkbox = container.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a number input with min/max for the number field", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Max Tokens");
|
||||
const numInput = container.querySelector("input[type='number']") as HTMLInputElement | null;
|
||||
expect(numInput).toBeTruthy();
|
||||
expect(numInput?.getAttribute("min")).toBe("100");
|
||||
expect(numInput?.getAttribute("max")).toBe("8000");
|
||||
});
|
||||
|
||||
it("renders a text input for the string field", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Prefix");
|
||||
const textInput = container.querySelector("input[type='text']") as HTMLInputElement | null;
|
||||
expect(textInput).toBeTruthy();
|
||||
expect(textInput?.value).toBe("hello");
|
||||
});
|
||||
|
||||
it("renders a select with 2 options for the select field", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Strategy");
|
||||
const select = container.querySelector("select") as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
const options = Array.from(select?.querySelectorAll("option") ?? []);
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].value).toBe("fast");
|
||||
expect(options[1].value).toBe("thorough");
|
||||
});
|
||||
|
||||
it("renders 2 checkboxes for the multiselect field", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Techniques");
|
||||
expect(container.textContent).toContain("Strip Comments");
|
||||
expect(container.textContent).toContain("Minify");
|
||||
// boolean field checkbox + 2 multiselect checkboxes = 3 total
|
||||
const allCheckboxes = Array.from(container.querySelectorAll("input[type='checkbox']"));
|
||||
expect(allCheckboxes).toHaveLength(3); // boolean + 2 multiselect
|
||||
});
|
||||
|
||||
it("calls onChange with flipped boolean when checkbox is toggled", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
// The first checkbox is the boolean field
|
||||
const checkbox = container.querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
expect(checkbox).toBeTruthy();
|
||||
|
||||
// React listens to the native "click" event for checkboxes; setting
|
||||
// .checked + dispatching a MouseEvent("click") is what triggers the
|
||||
// synthetic onChange handler in jsdom.
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...INITIAL_VALUE, enabled: true });
|
||||
});
|
||||
|
||||
it("calls onChange with new number when number input changes", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
const numInput = container.querySelector("input[type='number']") as HTMLInputElement;
|
||||
expect(numInput).toBeTruthy();
|
||||
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
|
||||
act(() => {
|
||||
nativeSetter?.call(numInput, "2048");
|
||||
numInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...INITIAL_VALUE, maxTokens: 2048 });
|
||||
});
|
||||
|
||||
it("calls onChange with updated array when a multiselect option is checked", async () => {
|
||||
const { EngineConfigForm } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigForm");
|
||||
const onChange = vi.fn();
|
||||
const container = mount(
|
||||
<EngineConfigForm schema={SCHEMA} value={INITIAL_VALUE} onChange={onChange} />
|
||||
);
|
||||
|
||||
// The multiselect checkboxes are the 2nd and 3rd checkboxes
|
||||
const allCheckboxes = Array.from(
|
||||
container.querySelectorAll("input[type='checkbox']")
|
||||
) as HTMLInputElement[];
|
||||
// Index 0 = boolean field, index 1 = "strip-comments", index 2 = "minify"
|
||||
const stripCommentsCheckbox = allCheckboxes[1];
|
||||
expect(stripCommentsCheckbox).toBeTruthy();
|
||||
|
||||
// React listens to native "click" for checkboxes to trigger synthetic onChange.
|
||||
act(() => {
|
||||
stripCommentsCheckbox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...INITIAL_VALUE,
|
||||
techniques: ["strip-comments"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mountInContainer(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore mocks first so any in-flight fetch promises settle without blocking
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) {
|
||||
roots.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
// Drain all remaining microtasks from effects that fired during unmount
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Mock fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINE_PAYLOAD = {
|
||||
engines: [
|
||||
{
|
||||
id: "headroom",
|
||||
name: "Headroom",
|
||||
description: "Headroom engine description",
|
||||
icon: "🗜️",
|
||||
stackable: true,
|
||||
stackPriority: 1,
|
||||
metadata: { description: "Headroom metadata description" },
|
||||
configSchema: [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "minRows",
|
||||
type: "number",
|
||||
label: "Min rows",
|
||||
defaultValue: 8,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const COMBO_PAYLOAD = {
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Default combo",
|
||||
pipeline: [],
|
||||
languagePacks: [],
|
||||
outputMode: null,
|
||||
};
|
||||
|
||||
const ANALYTICS_PAYLOAD = {
|
||||
engineId: "headroom",
|
||||
runs: 0,
|
||||
tokensSaved: 0,
|
||||
avgSavingsPercent: 0,
|
||||
days: 7,
|
||||
};
|
||||
|
||||
const SETTINGS_PAYLOAD = {
|
||||
enabled: true,
|
||||
engines: { headroom: { enabled: true } },
|
||||
aggressive: {
|
||||
summarizerEnabled: true,
|
||||
maxTokensPerMessage: 2048,
|
||||
minSavingsThreshold: 0.05,
|
||||
},
|
||||
};
|
||||
|
||||
function setupFetchMock() {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return new Response(JSON.stringify(ENGINE_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return new Response(JSON.stringify(SETTINGS_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/context/combos/default")) {
|
||||
return new Response(JSON.stringify(COMBO_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/context/analytics/engine")) {
|
||||
return new Response(JSON.stringify(ANALYTICS_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/compression/preview")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
original: "The original context contains duplicated details and verbose wording.",
|
||||
compressed: "Original context, deduplicated.",
|
||||
originalTokens: 11,
|
||||
compressedTokens: 4,
|
||||
savingsPct: 63.6,
|
||||
diff: [{ type: "removed", text: "duplicated details and verbose wording" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EngineConfigPage", () => {
|
||||
it("renders the engine name after fetching engine list", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
// Flush any pending microtasks from effects
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Headroom");
|
||||
});
|
||||
|
||||
it("does NOT render an engine on/off enable toggle (moved to the panel)", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The on/off enable control now lives only in the panel (/dashboard/context/settings).
|
||||
expect(container.querySelector("[data-toggle='enable']")).toBeNull();
|
||||
expect(container.textContent).not.toContain("Enable layer");
|
||||
});
|
||||
|
||||
it("renders the config form field label from fetched schema (EngineConfigForm mounted)", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// EngineConfigForm should render the "Min rows" field label from the schema
|
||||
expect(container.textContent).toContain("Min rows");
|
||||
});
|
||||
|
||||
it("keeps detailed config but renders no engine enable checkbox", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The on/off enable toggle (a checkbox with data-toggle="enable") is gone; the
|
||||
// detailed config form (the schema fields minus `enabled`) still renders.
|
||||
expect(container.querySelector("input[type='checkbox'][data-toggle='enable']")).toBeNull();
|
||||
expect(container.textContent).toContain("Min rows");
|
||||
expect(container.textContent).toContain("Configuration");
|
||||
});
|
||||
|
||||
it("renders preview original, compressed text, and diff returned by the API", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const previewButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Preview"
|
||||
);
|
||||
expect(previewButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
previewButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"The original context contains duplicated details and verbose wording."
|
||||
);
|
||||
expect(container.textContent).toContain("Original context, deduplicated.");
|
||||
expect(container.textContent).toContain("Diff");
|
||||
expect(container.textContent).toContain("duplicated details and verbose wording");
|
||||
});
|
||||
|
||||
it("shows empty-state text when analytics returns runs=0", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should show some "no data" copy
|
||||
const hasEmptyState =
|
||||
container.textContent?.includes("Sem dados") === true ||
|
||||
container.textContent?.includes("No data") === true;
|
||||
expect(hasEmptyState).toBe(true);
|
||||
});
|
||||
|
||||
it("points to the Compression Settings panel for enabling the layer", async () => {
|
||||
setupFetchMock();
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The on/off + level live in the panel now; the page surfaces a link to it.
|
||||
const settingsLink = container.querySelector('a[href="/dashboard/context/settings"]');
|
||||
expect(settingsLink).not.toBeNull();
|
||||
expect(container.textContent).toContain("Compression Settings");
|
||||
});
|
||||
|
||||
it("INVARIANT #1: handleSave writes the detailed sub-object to settings/compression, never PUTs combos/default", async () => {
|
||||
const AGGRESSIVE_PAYLOAD = {
|
||||
engines: [
|
||||
{
|
||||
id: "aggressive",
|
||||
name: "Aggressive",
|
||||
description: "Aggressive engine",
|
||||
icon: "🗜️",
|
||||
stackable: true,
|
||||
stackPriority: 30,
|
||||
metadata: { description: "Aggressive metadata" },
|
||||
configSchema: [
|
||||
{ key: "enabled", type: "boolean", label: "Enabled", defaultValue: true },
|
||||
{
|
||||
key: "maxTokensPerMessage",
|
||||
type: "number",
|
||||
label: "Max tokens per message",
|
||||
defaultValue: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const settingsPuts: { body: Record<string, unknown> }[] = [];
|
||||
const comboWrites: { method: string }[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url.includes("/api/compression/engines")) {
|
||||
return new Response(JSON.stringify(AGGRESSIVE_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (init?.method === "PUT") {
|
||||
settingsPuts.push({ body: JSON.parse(init.body as string) });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
enabled: true,
|
||||
engines: { aggressive: { enabled: true } },
|
||||
aggressive: { maxTokensPerMessage: 2048 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/context/combos/default")) {
|
||||
// A PUT/POST here would violate INVARIANT #1 (the route is a 410 shim).
|
||||
if (init?.method === "PUT" || init?.method === "POST") {
|
||||
comboWrites.push({ method: init.method });
|
||||
}
|
||||
return new Response(JSON.stringify(COMBO_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/context/analytics/engine")) {
|
||||
return new Response(JSON.stringify(ANALYTICS_PAYLOAD), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}
|
||||
);
|
||||
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="aggressive" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const salvarBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Save") || b.textContent?.includes("Salvar")
|
||||
);
|
||||
expect(salvarBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
salvarBtn?.click();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// INVARIANT #1: no write ever lands on the deprecated default-combo route.
|
||||
expect(comboWrites).toHaveLength(0);
|
||||
// The detailed config persists to the engine's sub-object on settings/compression.
|
||||
expect(settingsPuts.length).toBeGreaterThan(0);
|
||||
const aggressivePut = settingsPuts.find(
|
||||
(c) => typeof c.body.aggressive === "object" && c.body.aggressive !== null
|
||||
);
|
||||
expect(aggressivePut).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not crash when all fetch calls fail (fail-soft)", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { EngineConfigPage } =
|
||||
await import("../../../src/shared/components/compression/EngineConfigPage");
|
||||
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Component should still be mounted (not crashed)
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.parentNode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Polyfill ResizeObserver ────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ReactFlow's Handle/Position require a ReactFlowProvider context.
|
||||
// We stub the xyflow module so EngineNode can render without the provider.
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { EngineNode } =
|
||||
await import("@/app/(dashboard)/dashboard/compression/studio/nodes/EngineNode");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample data ───────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_ENGINE_DATA = {
|
||||
id: "engine-0",
|
||||
type: "engine",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {
|
||||
engine: "caveman",
|
||||
originalTokens: 900,
|
||||
compressedTokens: 729,
|
||||
savingsPercent: 19.0,
|
||||
techniquesUsed: ["filler-drop", "dedup"],
|
||||
durationMs: 5.3,
|
||||
stepState: "done" as const,
|
||||
label: "caveman",
|
||||
},
|
||||
};
|
||||
|
||||
// NodeProps-compatible shim
|
||||
const nodeProps = {
|
||||
...SAMPLE_ENGINE_DATA,
|
||||
selected: false,
|
||||
isConnectable: true,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
positionAbsoluteX: 200,
|
||||
positionAbsoluteY: 0,
|
||||
width: 180,
|
||||
height: 80,
|
||||
} as unknown as Parameters<typeof EngineNode>[0];
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EngineNode", () => {
|
||||
it("renders the engine name", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
expect(container.textContent).toContain("caveman");
|
||||
});
|
||||
|
||||
it("shows the savings percentage", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
const el = container.querySelector("[data-testid='savings-percent']");
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.textContent).toContain("19.0");
|
||||
});
|
||||
|
||||
it("shows technique names", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
expect(container.textContent).toContain("filler-drop");
|
||||
});
|
||||
|
||||
it("marks a skipped node (tokensIn === tokensOut)", () => {
|
||||
const skippedProps = {
|
||||
...nodeProps,
|
||||
data: {
|
||||
...SAMPLE_ENGINE_DATA.data,
|
||||
compressedTokens: SAMPLE_ENGINE_DATA.data.originalTokens,
|
||||
savingsPercent: 0,
|
||||
stepState: "skipped" as const,
|
||||
},
|
||||
};
|
||||
const container = mount(<EngineNode {...skippedProps} />);
|
||||
expect(container.textContent).toContain("skip");
|
||||
});
|
||||
|
||||
it("renders layer pills for rtk engine", () => {
|
||||
const rtkProps = {
|
||||
...nodeProps,
|
||||
data: {
|
||||
...SAMPLE_ENGINE_DATA.data,
|
||||
engine: "rtk",
|
||||
stepState: "done" as const,
|
||||
},
|
||||
};
|
||||
const container = mount(<EngineNode {...rtkProps} />);
|
||||
// rtk maps to L3, L4
|
||||
expect(container.textContent).toContain("L3");
|
||||
expect(container.textContent).toContain("L4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { runPreviewBatch } from "@/hooks/usePreviewCompression";
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
describe("runPreviewBatch fidelityGate", () => {
|
||||
it("includes fidelityGate:{enabled:true} in every preview payload when on", async () => {
|
||||
const payloads: any[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (_u: string, init: any) => {
|
||||
payloads.push(JSON.parse(init.body));
|
||||
return { ok: true, json: async () => ({
|
||||
original: "o", compressed: "c", originalTokens: 5, compressedTokens: 5, savingsPct: 0,
|
||||
mode: "stacked", durationMs: 1, engineBreakdown: [], diff: [], preservedBlocks: [], ruleRemovals: [],
|
||||
}) } as any;
|
||||
}));
|
||||
await runPreviewBatch({
|
||||
messages: [{ role: "user", content: "x" }],
|
||||
laneEngines: ["rtk"], activeEngines: ["rtk"], fidelityGate: true,
|
||||
});
|
||||
expect(payloads.length).toBe(2); // 1 lane + 1 combined
|
||||
expect(payloads.every((p) => p.fidelityGate?.enabled === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* tests/unit/ui/fleetAggregation.test.ts
|
||||
*
|
||||
* TDD for `aggregateComboEventsToSets` — fleet aggregation for Tela B U2.
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/fleetAggregation.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { aggregateComboEventsToSets } from "../../../src/app/(dashboard)/dashboard/combos/live/fleetAggregation.ts";
|
||||
import type { ComboEventInput } from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const WINDOW_MS = 10_000; // 10 seconds
|
||||
const NOW = 1_000_000; // fixed "now" for deterministic tests
|
||||
|
||||
function ev(
|
||||
type: "attempt" | "succeeded" | "failed",
|
||||
provider: string,
|
||||
timestampOffset: number // relative to NOW (negative = in the past)
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "fleet-combo",
|
||||
targetIndex: 0,
|
||||
provider,
|
||||
model: "m",
|
||||
type,
|
||||
timestamp: NOW + timestampOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// ── aggregateComboEventsToSets ────────────────────────────────────────────
|
||||
|
||||
describe("aggregateComboEventsToSets — basic categorization", () => {
|
||||
it("puts a recently failed provider in error set", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "openai", -1000)]; // 1s ago, within 10s window
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"), "openai should be in error set");
|
||||
assert.ok(!active.has("openai"), "openai should not be in active set");
|
||||
assert.ok(!last.has("openai"), "openai should not be in last set");
|
||||
});
|
||||
|
||||
it("puts a recent attempt provider in active set", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "anthropic", -500)];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("anthropic"), "anthropic should be in active set");
|
||||
assert.ok(!error.has("anthropic"));
|
||||
assert.ok(!last.has("anthropic"));
|
||||
});
|
||||
|
||||
it("puts a recent succeeded provider in active set", () => {
|
||||
const events: ComboEventInput[] = [ev("succeeded", "gemini", -2000)];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("gemini"), "gemini should be in active set");
|
||||
assert.ok(!error.has("gemini"));
|
||||
assert.ok(!last.has("gemini"));
|
||||
});
|
||||
|
||||
it("puts an old event provider in last set (outside window)", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "cohere", -(WINDOW_MS + 1))]; // just outside window
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(last.has("cohere"), "cohere should be in last set");
|
||||
assert.ok(!active.has("cohere"));
|
||||
assert.ok(!error.has("cohere"));
|
||||
});
|
||||
|
||||
it("puts an old failed provider in last set (not error)", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "mistral", -(WINDOW_MS + 5000))];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(last.has("mistral"), "old failed should be last, not error");
|
||||
assert.ok(!error.has("mistral"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboEventsToSets — multiple providers and events", () => {
|
||||
it("handles multiple providers independently", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -500), // recent → error
|
||||
ev("succeeded", "gemini", -1000), // recent → active
|
||||
ev("attempt", "cohere", -(WINDOW_MS + 1)), // old → last
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"));
|
||||
assert.ok(active.has("gemini"));
|
||||
assert.ok(last.has("cohere"));
|
||||
});
|
||||
|
||||
it("latest event for provider wins (error beats active for same provider)", () => {
|
||||
// Two events for openai: succeeded first (older), then failed (newer)
|
||||
const events: ComboEventInput[] = [
|
||||
ev("succeeded", "openai", -3000), // older, within window
|
||||
ev("failed", "openai", -500), // newer → should win
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"), "latest event (failed) should win");
|
||||
assert.ok(!active.has("openai"));
|
||||
});
|
||||
|
||||
it("latest event for provider wins (active beats old failure when newer event is success)", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -3000), // older failed, within window
|
||||
ev("succeeded", "openai", -500), // newer success → should win
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("openai"), "newer succeeded should win over older failed");
|
||||
assert.ok(!error.has("openai"));
|
||||
});
|
||||
|
||||
it("returns empty sets for empty events list", () => {
|
||||
const { active, error, last } = aggregateComboEventsToSets([], WINDOW_MS, NOW);
|
||||
|
||||
assert.equal(active.size, 0);
|
||||
assert.equal(error.size, 0);
|
||||
assert.equal(last.size, 0);
|
||||
});
|
||||
|
||||
it("a provider appears in at most one set", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -500),
|
||||
ev("attempt", "openai", -1000),
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
const inSets =
|
||||
(active.has("openai") ? 1 : 0) + (error.has("openai") ? 1 : 0) + (last.has("openai") ? 1 : 0);
|
||||
assert.equal(inSets, 1, "provider should be in exactly one set");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboEventsToSets — window boundary", () => {
|
||||
it("includes event exactly at the window boundary (now - windowMs) as 'last'", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "boundary", -WINDOW_MS)];
|
||||
const { active, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
// timestamp === NOW - WINDOW_MS: age === WINDOW_MS → not within (age < windowMs)
|
||||
assert.ok(
|
||||
last.has("boundary") || !active.has("boundary"),
|
||||
"boundary event should be last or absent from active"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call Date.now() — pure function (same output with same now)", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "openai", -500)];
|
||||
const r1 = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
const r2 = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.deepEqual([...r1.error], [...r2.error]);
|
||||
assert.deepEqual([...r1.active], [...r2.active]);
|
||||
assert.deepEqual([...r1.last], [...r2.last]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
|
||||
beforeAll(() => {
|
||||
// ReactFlow relies on ResizeObserver, which jsdom does not implement.
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
{ id: "a", position: { x: 0, y: 0 }, data: { label: "A" } },
|
||||
{ id: "b", position: { x: 120, y: 0 }, data: { label: "B" } },
|
||||
];
|
||||
const edges = [{ id: "a-b", source: "a", target: "b" }];
|
||||
|
||||
describe("FlowCanvas (U0 — shared ReactFlow wrapper)", () => {
|
||||
it("renders the canvas with Controls and hides the attribution", () => {
|
||||
const container = mount(<FlowCanvas nodes={nodes} edges={edges} fitKey="x" />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
expect(container.querySelector(".react-flow__controls")).toBeTruthy();
|
||||
// proOptions.hideAttribution => the attribution element must not render.
|
||||
expect(container.querySelector(".react-flow__attribution")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the provided container className for sizing/theming", () => {
|
||||
const container = mount(
|
||||
<FlowCanvas nodes={nodes} edges={edges} className="omni-test-canvas h-[300px]" />
|
||||
);
|
||||
expect(container.querySelector(".omni-test-canvas")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/unit/ui/fuzzyDedupToggle.test.tsx
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { runPreviewBatch } from "@/hooks/usePreviewCompression";
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
describe("runPreviewBatch fuzzyDedup", () => {
|
||||
it("includes fuzzyDedup:{enabled:true} in every preview payload when on", async () => {
|
||||
const payloads: any[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (_u: string, init: any) => {
|
||||
payloads.push(JSON.parse(init.body));
|
||||
return { ok: true, json: async () => ({
|
||||
original: "o", compressed: "c", originalTokens: 5, compressedTokens: 5, savingsPct: 0,
|
||||
mode: "stacked", durationMs: 1, engineBreakdown: [], diff: [], preservedBlocks: [], ruleRemovals: [],
|
||||
}) } as any;
|
||||
}));
|
||||
await runPreviewBatch({
|
||||
messages: [{ role: "user", content: "x" }], laneEngines: ["session-dedup"], activeEngines: ["session-dedup"], fuzzyDedup: true,
|
||||
});
|
||||
expect(payloads.length).toBe(2);
|
||||
expect(payloads.every((p) => p.fuzzyDedup?.enabled === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression guard for the infinite render loop on /dashboard/cli-agents/hermes-agent.
|
||||
//
|
||||
// HermesAgentToolCard used to list `currentRoles` in the dependency array of the
|
||||
// effect that calls loadCurrentConfig(); loadCurrentConfig() sets currentRoles to a
|
||||
// fresh object on every fetch, so the effect re-fired → refetched → re-set → … forever.
|
||||
// On the detail page isExpanded is hardcoded true, so it spun without end and spammed
|
||||
// GET /api/cli-tools/hermes-agent-settings in the console. This test mounts the card
|
||||
// expanded and asserts the settings endpoint is fetched a BOUNDED number of times.
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ModelSelectModal: () => <div data-testid="ModelSelectModal" />,
|
||||
}));
|
||||
|
||||
const { default: HermesAgentToolCard } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard"
|
||||
);
|
||||
|
||||
const SETTINGS_ENDPOINT = "/api/cli-tools/hermes-agent-settings";
|
||||
const containers: HTMLElement[] = [];
|
||||
let settingsCalls = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
settingsCalls = 0;
|
||||
|
||||
// fetch mock: count settings GETs and return valid roles. A safety valve breaks any
|
||||
// runaway loop after 8 calls (returns a non-success body so loadCurrentConfig stops
|
||||
// mutating currentRoles) so the buggy code FAILS the assertion instead of hanging.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: any) => {
|
||||
const u = typeof url === "string" ? url : String(url);
|
||||
if (u.includes(SETTINGS_ENDPOINT)) {
|
||||
settingsCalls++;
|
||||
if (settingsCalls > 8) {
|
||||
return { ok: false, json: async () => ({ success: false }) } as any;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roles: {
|
||||
default: {
|
||||
model: "openai/gpt-4o",
|
||||
provider: "omniroute",
|
||||
base_url: "http://localhost:20128",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
return { ok: true, json: async () => ({}) } as any;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderExpandedCard() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<HermesAgentToolCard
|
||||
tool={{ name: "Hermes Agent", description: "Hermes Agent" }}
|
||||
isExpanded={true}
|
||||
baseUrl="http://localhost:20128"
|
||||
apiKeys={[{ id: "key-1" }]}
|
||||
activeProviders={[]}
|
||||
batchStatus={null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("HermesAgentToolCard — config-load effect", () => {
|
||||
it("loads current config once when expanded (does not loop on currentRoles)", async () => {
|
||||
renderExpandedCard();
|
||||
|
||||
// Drive every pending microtask/effect to quiescence. On the buggy code each
|
||||
// settings response re-triggered the effect, so these flushes would keep firing
|
||||
// fetches up to the safety valve; on the fixed code it settles after one fetch.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
expect(settingsCalls).toBeLessThanOrEqual(2);
|
||||
expect(settingsCalls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let lastModelSelectProps: any = null;
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ModelSelectModal: (props: any) => {
|
||||
lastModelSelectProps = props;
|
||||
return <div data-testid="ModelSelectModal" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: HermesAgentToolCard } =
|
||||
await import("@/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function renderCard() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<HermesAgentToolCard
|
||||
tool={{ name: "Hermes Agent", description: "Hermes Agent" }}
|
||||
isExpanded={false}
|
||||
baseUrl="http://localhost:3000"
|
||||
apiKeys={[{ id: "key-1" }]}
|
||||
activeProviders={[]}
|
||||
batchStatus={null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
lastModelSelectProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HermesAgentToolCard", () => {
|
||||
it("keeps OpenCode Free available in the model picker even with no active connections", async () => {
|
||||
renderCard();
|
||||
await act(async () => {});
|
||||
|
||||
expect(lastModelSelectProps).toBeTruthy();
|
||||
expect(lastModelSelectProps.activeProviders).toEqual([]);
|
||||
expect(lastModelSelectProps.alwaysIncludeProviders).toContain("opencode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tests for HistoricSessionBanner — render with sessionName/null + backToLive callback
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Pure logic tests — no DOM needed (no next-intl in node:test runner)
|
||||
|
||||
describe("HistoricSessionBanner logic", () => {
|
||||
it("resolves session display name when provided", () => {
|
||||
const sessionName = "My Test Session";
|
||||
const display = sessionName ?? "Untitled session";
|
||||
assert.equal(display, "My Test Session");
|
||||
});
|
||||
|
||||
it("falls back to untitledSession when sessionName is null", () => {
|
||||
const sessionName: string | null = null;
|
||||
const fallback = "Untitled session";
|
||||
const display = sessionName ?? fallback;
|
||||
assert.equal(display, "Untitled session");
|
||||
});
|
||||
|
||||
it("falls back to untitledSession when sessionName is empty string", () => {
|
||||
const sessionName: string | null = "";
|
||||
// empty string is falsy — banner should show fallback
|
||||
const fallback = "Untitled session";
|
||||
const display = sessionName || fallback;
|
||||
assert.equal(display, "Untitled session");
|
||||
});
|
||||
|
||||
it("onBackToLive callback is invoked on click", () => {
|
||||
let called = false;
|
||||
const onBackToLive = () => { called = true; };
|
||||
// Simulate button click
|
||||
onBackToLive();
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
it("banner renders when selectedSessionId is defined (gate logic)", () => {
|
||||
const selectedSessionId: string | undefined = "session-abc";
|
||||
// In TrafficInspectorPageClient the banner renders when this is not undefined
|
||||
const shouldRender = selectedSessionId !== undefined;
|
||||
assert.equal(shouldRender, true);
|
||||
});
|
||||
|
||||
it("banner does not render when selectedSessionId is undefined (gate logic)", () => {
|
||||
const selectedSessionId: string | undefined = undefined;
|
||||
const shouldRender = selectedSessionId !== undefined;
|
||||
assert.equal(shouldRender, false);
|
||||
});
|
||||
|
||||
it("backToLive sets sessionId to undefined", () => {
|
||||
let sessionId: string | undefined = "session-abc";
|
||||
const backToLive = () => { sessionId = undefined; };
|
||||
backToLive();
|
||||
assert.equal(sessionId, undefined);
|
||||
});
|
||||
|
||||
it("session name is looked up from sessions array", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", name: "Session Alpha", startedAt: "2024-01-01", requestCount: 5 },
|
||||
{ id: "s2", name: undefined, startedAt: "2024-01-02", requestCount: 3 },
|
||||
];
|
||||
const selectedId = "s1";
|
||||
const name = sessions.find((s) => s.id === selectedId)?.name ?? null;
|
||||
assert.equal(name, "Session Alpha");
|
||||
});
|
||||
|
||||
it("session name is null when session not found in array", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", name: "Session Alpha", startedAt: "2024-01-01", requestCount: 5 },
|
||||
];
|
||||
const selectedId = "not-exist";
|
||||
const name = sessions.find((s) => s.id === selectedId)?.name ?? null;
|
||||
assert.equal(name, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
function makeTranslator() {
|
||||
const t = (key: string) => key;
|
||||
t.rich = (key: string) => key;
|
||||
return t;
|
||||
}
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => makeTranslator(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a href={String(href)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () =>
|
||||
function DynamicStub() {
|
||||
return <div data-testid="dynamic-component" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <section>{children}</section>,
|
||||
CardSkeleton: () => <div data-testid="card-skeleton" />,
|
||||
Button: ({
|
||||
children,
|
||||
loading: _loading,
|
||||
fullWidth: _fullWidth,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => <button {...props}>{children}</button>,
|
||||
Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
|
||||
isOpen ? <div role="dialog">{children}</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
const notifyMock = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
addNotification: vi.fn(),
|
||||
};
|
||||
|
||||
function useNotificationStoreMock() {
|
||||
return notifyMock;
|
||||
}
|
||||
useNotificationStoreMock.getState = () => notifyMock;
|
||||
|
||||
vi.mock("@/store/notificationStore", () => ({
|
||||
useNotificationStore: useNotificationStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useElectron", () => ({
|
||||
useIsElectron: () => false,
|
||||
useOpenExternal: () => ({ openExternal: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/clipboard", () => ({
|
||||
copyToClipboard: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { default: HomePageClient } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/HomePageClient");
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
showQuickStartOnHome: true,
|
||||
showProviderTopologyOnHome: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === "/api/providers") {
|
||||
return Promise.resolve(jsonResponse({ connections: [] }));
|
||||
}
|
||||
if (url === "/api/models") {
|
||||
return Promise.resolve(jsonResponse({ models: [] }));
|
||||
}
|
||||
if (url === "/api/system/version") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
current: "0.0.0-test",
|
||||
latest: "0.0.0-test",
|
||||
updateAvailable: false,
|
||||
channel: "test",
|
||||
autoUpdateSupported: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === "/api/provider-nodes") {
|
||||
return Promise.resolve(jsonResponse({ nodes: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
})
|
||||
);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the dashboard home client without throwing an internal server error", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HomePageClient machineId="test-machine" />);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Internal Server Error");
|
||||
expect(container.textContent).toContain("quickStart");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// #4606: the provider-topology card was extracted into HomeProviderTopologySection
|
||||
// and its activity fetch gated behind widget visibility in HomePageClient
|
||||
// (`appearanceSettingsLoaded && showProviderTopologyOnHome`). This guards the
|
||||
// extracted section: it renders the topology card and feeds live active-requests
|
||||
// through selectActiveRequests into ProviderTopology (Rule #18 for the change).
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => (props: Record<string, unknown>) => (
|
||||
<div data-testid="provider-topology" data-providers={String((props.providers as unknown[])?.length ?? 0)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div data-testid="card">{children}</div>,
|
||||
}));
|
||||
const liveRequestsMock = vi.fn(() => ({ activeRequests: [] as unknown[] }));
|
||||
vi.mock("@/hooks/useLiveDashboard", () => ({
|
||||
useLiveRequests: () => liveRequestsMock(),
|
||||
}));
|
||||
|
||||
const { HomeProviderTopologySection } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/HomeProviderTopologySection"
|
||||
);
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the topology card and forwards providers to ProviderTopology", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<HomeProviderTopologySection
|
||||
providers={[
|
||||
{ id: "p1", provider: "openai", name: "OpenAI" },
|
||||
{ id: "p2", provider: "anthropic", name: "Anthropic" },
|
||||
]}
|
||||
lastProvider="openai"
|
||||
errorProvider=""
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='card']")).not.toBeNull();
|
||||
const topology = container.querySelector("[data-testid='provider-topology']");
|
||||
expect(topology).not.toBeNull();
|
||||
expect(topology?.getAttribute("data-providers")).toBe("2");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLiveRequests } from "../../../src/hooks/useLiveDashboard";
|
||||
|
||||
function LiveRequestsHarness({ enabled }: { enabled: boolean }) {
|
||||
useLiveRequests({ enabled });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("home topology hidden networking (#4596)", () => {
|
||||
const websocketMock = vi.fn();
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
websocketMock.mockClear();
|
||||
vi.stubGlobal(
|
||||
"WebSocket",
|
||||
class WebSocketMock {
|
||||
static OPEN = 1;
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
websocketMock(url);
|
||||
}
|
||||
|
||||
send() {}
|
||||
close() {}
|
||||
}
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does NOT open a WebSocket when the topology section is disabled", () => {
|
||||
act(() => {
|
||||
root!.render(<LiveRequestsHarness enabled={false} />);
|
||||
});
|
||||
expect(websocketMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a WebSocket when the topology section is enabled", () => {
|
||||
act(() => {
|
||||
root!.render(<LiveRequestsHarness enabled={true} />);
|
||||
});
|
||||
expect(websocketMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// Regression guard for #5991 — clicking "Update now" showed an "Internal Server
|
||||
// Error" screen (Minified React error #31). The handler POSTs /api/system/version
|
||||
// (a loopback-only auto-update endpoint) and, on a non-OK JSON response, did:
|
||||
// notify.error(data.error || "Failed to start update.");
|
||||
// OmniRoute's error envelope is `{ error: { code, message, correlation_id } }`, so
|
||||
// `data.error` is an OBJECT. notify.error rendered that object as a React child →
|
||||
// React #31 crash. The fix funnels the body through extractApiErrorMessage() (the
|
||||
// same helper introduced in #5340) so a string always reaches the toast.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(
|
||||
resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("HomePageClient imports the safe API error extractor", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/import\s*\{\s*extractApiErrorMessage\s*\}\s*from\s*["']@\/shared\/http\/apiErrorMessage["']/,
|
||||
"HomePageClient must import extractApiErrorMessage to render API errors safely (#5991)"
|
||||
);
|
||||
});
|
||||
|
||||
test("the update-error handler funnels the body through extractApiErrorMessage (#5991)", () => {
|
||||
// The update failure path must extract a string, not hand the raw envelope object
|
||||
// (which triggers React #31) to notify.error.
|
||||
assert.match(
|
||||
source,
|
||||
/notify\.error\(\s*extractApiErrorMessage\(\s*data\s*,/,
|
||||
"the update-error notify.error must use extractApiErrorMessage(data, …) (#5991)"
|
||||
);
|
||||
});
|
||||
|
||||
test("the update-error handler never passes the raw error object to notify.error (#5991)", () => {
|
||||
// The pre-fix pattern `notify.error(data.error || …)` rendered an object as a React
|
||||
// child. It must not come back.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/notify\.error\(\s*data\.error\b/,
|
||||
"notify.error(data.error …) renders the error envelope object as a React child → React #31 (#5991)"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// Regression guard for the escalated mesh report "Nobody solve this color issue 🤫":
|
||||
// the update-step status icon for the `warning` state used a bare `text-yellow-500`
|
||||
// (Tailwind #eab308, no dark: variant) while its `done`/`failed` siblings use the
|
||||
// vivid `text-green-500` / `text-red-500`. yellow-500 has poor contrast on light
|
||||
// backgrounds (~1.9:1, fails WCAG) and is inconsistent with the project's warning
|
||||
// convention, which is `amber` everywhere else in this same component (e.g. the
|
||||
// `bg-amber-500/10 text-amber-500` badge). The warning icon must use amber, not yellow.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(
|
||||
resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// Isolate the update-step status-icon block: the `warning` material symbol icon span.
|
||||
// Match the className on the span immediately preceding the `warning` glyph.
|
||||
const warningIconMatch = source.match(
|
||||
/className="material-symbols-outlined ([^"]*)"[^>]*>\s*warning\s*</
|
||||
);
|
||||
|
||||
test("update-step warning icon renders with an amber color, not bare yellow", () => {
|
||||
assert.ok(
|
||||
warningIconMatch,
|
||||
"expected to find the update-step `warning` material-symbols icon span in HomePageClient.tsx"
|
||||
);
|
||||
const className = warningIconMatch![1];
|
||||
assert.ok(
|
||||
className.includes("text-amber-500"),
|
||||
`warning icon should use the project's amber warning convention; got: "${className}"`
|
||||
);
|
||||
assert.ok(
|
||||
!className.includes("text-yellow-500"),
|
||||
"warning icon must not use the low-contrast bare `text-yellow-500` (no dark variant, fails WCAG on light backgrounds)"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/unit/ui/ionizerLane.test.tsx
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LANE_ENGINES } from "@/app/(dashboard)/dashboard/compression/studio/PlaygroundInput";
|
||||
describe("studio lane engines", () => {
|
||||
it("includes the ionizer engine so it has its own playground lane", () => {
|
||||
expect(LANE_ENGINES).toContain("ionizer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* tests/unit/ui/live-compression-accumulate.test.ts
|
||||
*
|
||||
* F5.1 coverage gap (F3.3): the `useLiveCompression` accumulator (`accumulateRun`)
|
||||
* — which folds incoming `compression.completed` payloads into the run list — was
|
||||
* exported "for unit tests" but never tested. This pins ordering + the cap.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { accumulateRun } from "../../../src/hooks/useLiveCompression.ts";
|
||||
import type { CompressionRunModel } from "../../../src/app/(dashboard)/dashboard/compression/studio/compressionFlowModel.ts";
|
||||
import type { CompressionCompletedPayload } from "../../../src/lib/events/types.ts";
|
||||
|
||||
function payload(requestId: string): CompressionCompletedPayload {
|
||||
return {
|
||||
requestId,
|
||||
comboId: "my-combo",
|
||||
mode: "stacked",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 40,
|
||||
engineBreakdown: [
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 40,
|
||||
techniquesUsed: ["tool-output-trim"],
|
||||
durationMs: 3,
|
||||
},
|
||||
],
|
||||
timestamp: 1718000000000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("accumulateRun — live compression run accumulator (F3.3)", () => {
|
||||
it("adds the first run derived from the payload (requestId carried through)", () => {
|
||||
const runs = accumulateRun([], payload("r1"));
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].requestId, "r1");
|
||||
assert.equal(runs[0].mode, "stacked");
|
||||
});
|
||||
|
||||
it("prepends so the list is most-recent-first", () => {
|
||||
let runs: CompressionRunModel[] = [];
|
||||
runs = accumulateRun(runs, payload("r1"));
|
||||
runs = accumulateRun(runs, payload("r2"));
|
||||
runs = accumulateRun(runs, payload("r3"));
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.requestId),
|
||||
["r3", "r2", "r1"],
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the list at maxRuns, dropping the oldest", () => {
|
||||
let runs: CompressionRunModel[] = [];
|
||||
for (const id of ["a", "b", "c", "d"]) {
|
||||
runs = accumulateRun(runs, payload(id), /* maxRuns */ 2);
|
||||
}
|
||||
assert.equal(runs.length, 2, "never grows beyond maxRuns");
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.requestId),
|
||||
["d", "c"],
|
||||
"keeps the 2 newest, newest-first; oldest (a, b) dropped",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mutate the previous array (returns a new list)", () => {
|
||||
const prev = accumulateRun([], payload("r1"));
|
||||
const next = accumulateRun(prev, payload("r2"));
|
||||
assert.equal(prev.length, 1, "input array is left untouched");
|
||||
assert.notEqual(prev, next);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
|
||||
vi.mock("@/hooks/useLiveCompression", () => ({
|
||||
useLiveCompression: () => ({ runs: [], lastRun: null, getRunById: () => undefined, isConnected: false, reconnect: () => {} }),
|
||||
}));
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual<any>("@xyflow/react");
|
||||
return { ...actual, Handle: () => null };
|
||||
});
|
||||
|
||||
let container: HTMLElement; let root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
(globalThis as any).ResizeObserver ||= class { observe(){} unobserve(){} disconnect(){} };
|
||||
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); document.body.innerHTML = ""; vi.restoreAllMocks(); });
|
||||
describe("live page", () => {
|
||||
it("renders the WS cockpit (empty state when no traffic)", async () => {
|
||||
const { default: LivePage } = await import("@/app/(dashboard)/dashboard/compression/live/page");
|
||||
await act(async () => { root.render(<LivePage />); });
|
||||
expect(container.querySelector('[data-testid="compression-cockpit-empty"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── Import component at module level (after mocks) ────────────────────────────
|
||||
|
||||
const { default: MarkdownMessage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/playground/components/MarkdownMessage"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderMarkdown(content: string, className?: string): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<MarkdownMessage content={content} className={className} />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
async function waitForCondition(fn: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MarkdownMessage", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders a code block as <pre><code>", async () => {
|
||||
const el = renderMarkdown("```js\nconsole.log(1)\n```");
|
||||
// Wait for the component to render
|
||||
await waitForCondition(() => el.innerHTML.length > 0);
|
||||
|
||||
const pre = el.querySelector("pre");
|
||||
const code = el.querySelector("code");
|
||||
expect(pre || code).toBeTruthy();
|
||||
expect(el.innerHTML).toContain("console.log");
|
||||
});
|
||||
|
||||
it("renders a markdown table as <table>", async () => {
|
||||
const tableMarkdown = `
|
||||
| Name | Age |
|
||||
|------|-----|
|
||||
| Alice | 30 |
|
||||
| Bob | 25 |
|
||||
`;
|
||||
const el = renderMarkdown(tableMarkdown);
|
||||
await waitForCondition(() => el.innerHTML.length > 0);
|
||||
|
||||
const table = el.querySelector("table");
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
const cells = el.querySelectorAll("td");
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a markdown list as <ul><li>", async () => {
|
||||
const listMarkdown = `
|
||||
- Item one
|
||||
- Item two
|
||||
- Item three
|
||||
`;
|
||||
const el = renderMarkdown(listMarkdown);
|
||||
await waitForCondition(() => el.querySelector("ul") !== null);
|
||||
|
||||
const ul = el.querySelector("ul");
|
||||
expect(ul).toBeTruthy();
|
||||
|
||||
const listItems = el.querySelectorAll("li");
|
||||
expect(listItems.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders a markdown link as <a href>", async () => {
|
||||
const el = renderMarkdown("[Click here](https://example.com)");
|
||||
await waitForCondition(() => el.querySelector("a") !== null);
|
||||
|
||||
const anchor = el.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor?.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("security: <script> tags appear as literal text, not executed", () => {
|
||||
const xssContent = "<script>alert(1)</script>";
|
||||
const el = renderMarkdown(xssContent);
|
||||
|
||||
// Should NOT contain a <script> element in the DOM
|
||||
const scriptEl = el.querySelector("script");
|
||||
expect(scriptEl).toBeNull();
|
||||
|
||||
// react-markdown by default does not render raw HTML
|
||||
// The content should not create a <script> tag
|
||||
const innerHTML = el.innerHTML;
|
||||
expect(innerHTML).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders plain text content", async () => {
|
||||
const el = renderMarkdown("Hello, world!");
|
||||
await waitForCondition(() => el.textContent !== null && el.textContent.includes("Hello"));
|
||||
|
||||
expect(el.textContent).toContain("Hello, world!");
|
||||
});
|
||||
|
||||
it("accepts optional className prop", () => {
|
||||
const el = renderMarkdown("Text", "my-custom-class");
|
||||
|
||||
// The wrapper div should have the class
|
||||
const wrapper = el.firstElementChild;
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper?.classList.contains("my-custom-class")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders strong and emphasis formatting", async () => {
|
||||
const el = renderMarkdown("**bold text** and _italic text_");
|
||||
await waitForCondition(
|
||||
() => el.querySelector("strong") !== null || el.querySelector("em") !== null,
|
||||
);
|
||||
|
||||
const strong = el.querySelector("strong");
|
||||
const em = el.querySelector("em");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(em).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) return `${key}:${JSON.stringify(values)}`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock shared components
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", { className: "card" }, children),
|
||||
Badge: ({ children, variant, title }: { children: React.ReactNode; variant?: string; title?: string }) =>
|
||||
React.createElement("span", { "data-variant": variant, title }, children),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
variant,
|
||||
size,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
"data-testid"?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick,
|
||||
disabled: disabled || loading,
|
||||
"data-variant": variant,
|
||||
"data-testid": testId,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
onKeyDown,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
onKeyDown,
|
||||
}),
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
}) => React.createElement("select", { value, onChange }, children),
|
||||
Modal: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "modal", "data-title": title },
|
||||
React.createElement("button", { onClick: onClose, "data-testid": "modal-close" }, "X"),
|
||||
children,
|
||||
footer,
|
||||
)
|
||||
: null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal",
|
||||
() => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
memory: unknown;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? React.createElement("div", { "data-testid": "edit-memory-modal" }, [
|
||||
React.createElement("button", { key: "close", onClick: onClose }, "close"),
|
||||
])
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const MOCK_MEMORIES = [
|
||||
{
|
||||
id: "mem-1",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: null,
|
||||
type: "factual",
|
||||
key: "user.name",
|
||||
content: "Alice",
|
||||
metadata: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
id: "mem-2",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: null,
|
||||
type: "episodic",
|
||||
key: "event.meeting",
|
||||
content: "Had a meeting",
|
||||
metadata: {},
|
||||
createdAt: "2026-01-02T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
expiresAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MemoriesTab", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: 2,
|
||||
tokensUsed: 150,
|
||||
hitRate: 0.75,
|
||||
cacheStats: { hits: 3, misses: 1 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders memories after fetch", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
// Wait for the 300ms debounce
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
expect(container.textContent).toContain("user.name");
|
||||
expect(container.textContent).toContain("Alice");
|
||||
});
|
||||
|
||||
it("shows hit rate card when cacheStats.hits + misses > 0", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
// hitRate is shown since cacheStats.hits=3, misses=1
|
||||
expect(container.textContent).toContain("hitRate");
|
||||
});
|
||||
|
||||
it("does not show hit rate when cacheStats is 0/0", async () => {
|
||||
vi.resetModules();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: 2,
|
||||
tokensUsed: 0,
|
||||
hitRate: 0,
|
||||
cacheStats: { hits: 0, misses: 0 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
// hitRate card should NOT appear
|
||||
expect(container.querySelector("[data-testid='hit-rate-card']")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens edit modal when pencil button is clicked", async () => {
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
const editBtn = container.querySelector("[data-testid='edit-memory-mem-1']") as HTMLButtonElement | null;
|
||||
expect(editBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
editBtn?.click();
|
||||
});
|
||||
expect(container.querySelector("[data-testid='edit-memory-modal']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no memories returned", async () => {
|
||||
vi.resetModules();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [],
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
stats: { total: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
}),
|
||||
});
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
expect(container.querySelector("[data-testid='memories-empty-state']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls DELETE when delete confirmed", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: MOCK_MEMORIES,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
}),
|
||||
});
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
globalThis.fetch = mockFetch;
|
||||
const { default: MemoriesTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoriesTab />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
const deleteBtn = container.querySelector("[data-testid='delete-memory-mem-1']") as HTMLButtonElement | null;
|
||||
expect(deleteBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
deleteBtn?.click();
|
||||
});
|
||||
// Confirm modal shown
|
||||
const modal = container.querySelector("[data-testid='modal']");
|
||||
expect(modal).toBeTruthy();
|
||||
// Find danger button inside modal
|
||||
const dangerBtns = Array.from(container.querySelectorAll("button")).filter(
|
||||
(b) => b.getAttribute("data-variant") === "danger",
|
||||
);
|
||||
expect(dangerBtns.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
dangerBtns[0].click();
|
||||
});
|
||||
// DELETE should have been called
|
||||
const deleteCalls = (mockFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c: [string, ...unknown[]]) =>
|
||||
typeof c[0] === "string" && c[0].includes("mem-1") && c[1] && (c[1] as { method: string }).method === "DELETE",
|
||||
);
|
||||
expect(deleteCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mutable state for controlling what tab the navigation mock returns
|
||||
let mockTabValue: string | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === "tab" ? mockTabValue : null),
|
||||
toString: () => (mockTabValue ? `tab=${mockTabValue}` : ""),
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return `${key}:${JSON.stringify(values)}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
getTranslations: () => async (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: () => ({ data: null, error: null, isLoading: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock all child tab components + concept card
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/MemoryConceptCard",
|
||||
() => ({
|
||||
default: () => React.createElement("div", { "data-testid": "concept-card" }, "ConceptCard"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "memories-tab-content" }, "MemoriesTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/PlaygroundTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "playground-tab-content" }, "PlaygroundTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab",
|
||||
() => ({
|
||||
default: () =>
|
||||
React.createElement("div", { "data-testid": "engine-tab-content" }, "EngineTab"),
|
||||
}),
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MemoryPage", () => {
|
||||
beforeEach(() => {
|
||||
mockTabValue = null;
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the concept card", async () => {
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='concept-card']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 3 tab buttons", async () => {
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
const tabs = ["memories", "playground", "engine"];
|
||||
for (const tab of tabs) {
|
||||
expect(container.querySelector(`[data-testid='tab-${tab}']`)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to memories tab (tab=null)", async () => {
|
||||
mockTabValue = null;
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='memories-tab-content']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='playground-tab-content']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='engine-tab-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows playground tab when ?tab=playground", async () => {
|
||||
mockTabValue = "playground";
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='playground-tab-content']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='memories-tab-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows engine tab when ?tab=engine", async () => {
|
||||
mockTabValue = "engine";
|
||||
const { default: MemoryPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/page"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<MemoryPage />);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='engine-tab-content']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MitmProxyError boundary", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a recoverable fallback instead of throwing", async () => {
|
||||
const { default: MitmProxyError } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/system/mitm-proxy/error");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const reset = vi.fn();
|
||||
const error = Object.assign(new Error("boom"), { digest: "abc123" });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<MitmProxyError error={error} reset={reset} />);
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='alert']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("Failed to load MITM proxy");
|
||||
});
|
||||
|
||||
it("calls reset() exactly once when Try Again is clicked", async () => {
|
||||
const { default: MitmProxyError } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/system/mitm-proxy/error");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const reset = vi.fn();
|
||||
const error = Object.assign(new Error("boom"), { digest: undefined });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<MitmProxyError error={error} reset={reset} />);
|
||||
});
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Try Again"
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for the MITM Proxy "page moved" banner (C4).
|
||||
* Validates:
|
||||
* - Banner renders with pageMoved.title text
|
||||
* - "Go now" button triggers router.replace
|
||||
* - Auto-redirect is set up via setTimeout
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockReplace = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (ns: string) => (key: string) => `${ns}.${key}`,
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("MitmProxyMovedPage — page-moved banner (C4)", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.useFakeTimers();
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders with pageMoved.title text", async () => {
|
||||
const { default: MitmProxyMovedPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/system/mitm-proxy/page"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(MitmProxyMovedPage));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("agentBridge.pageMoved.title");
|
||||
});
|
||||
|
||||
it("renders pageMoved.message text", async () => {
|
||||
const { default: MitmProxyMovedPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/system/mitm-proxy/page"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(MitmProxyMovedPage));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("agentBridge.pageMoved.message");
|
||||
});
|
||||
|
||||
it("clicking goNow button calls router.replace with agent-bridge path", async () => {
|
||||
const { default: MitmProxyMovedPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/system/mitm-proxy/page"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(MitmProxyMovedPage));
|
||||
});
|
||||
|
||||
const goNowBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("agentBridge.pageMoved.goNow")
|
||||
);
|
||||
expect(goNowBtn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
goNowBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith("/dashboard/tools/agent-bridge");
|
||||
});
|
||||
|
||||
it("auto-redirect fires after 2500ms via setTimeout", async () => {
|
||||
const { default: MitmProxyMovedPage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/system/mitm-proxy/page"
|
||||
);
|
||||
|
||||
const container = makeContainer();
|
||||
await act(async () => {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(MitmProxyMovedPage));
|
||||
});
|
||||
|
||||
// Not yet redirected
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timers past the 2500ms threshold
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2600);
|
||||
});
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith("/dashboard/tools/agent-bridge");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TDD tests for click-to-edit model alias inline edit affordance.
|
||||
* Ported from decolua/9router#2130.
|
||||
*
|
||||
* Covers PassthroughModelRow and ModelRow:
|
||||
* 1) Clicking the alias span enters edit mode (shows an input).
|
||||
* 2) Typing a new value and pressing Enter calls onSetAlias with the new value.
|
||||
* 3) Pressing Escape cancels without calling onSetAlias.
|
||||
* 4) Blur (losing focus) submits like Enter.
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
key
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Badge: ({ children }: any) => <span>{children}</span>,
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover",
|
||||
() => ({ default: () => null })
|
||||
);
|
||||
|
||||
vi.mock("@/shared/utils/modelCatalogSearch", () => ({
|
||||
getModelCatalogSourceLabel: () => "system",
|
||||
normalizeModelCatalogSource: () => "system",
|
||||
}));
|
||||
|
||||
const { default: PassthroughModelRow } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow"
|
||||
);
|
||||
|
||||
const { default: ModelRow } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow"
|
||||
);
|
||||
|
||||
const t = (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
key
|
||||
);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const noop = async () => {};
|
||||
const noopSync = () => {};
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function mountPassthrough(props: Record<string, unknown> = {}) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<PassthroughModelRow
|
||||
modelId="gpt-4o"
|
||||
fullModel="openai/gpt-4o"
|
||||
alias={null}
|
||||
onCopy={noopSync}
|
||||
onSetAlias={noopSync}
|
||||
t={t}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => false}
|
||||
saveModelCompatFlags={noopSync}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function mountModelRow(props: Record<string, unknown> = {}) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelRow
|
||||
model={{ id: "gpt-4o", name: "GPT-4o" }}
|
||||
fullModel="openai/gpt-4o"
|
||||
provider="openai"
|
||||
alias={undefined}
|
||||
onCopy={noopSync}
|
||||
onSetAlias={noopSync}
|
||||
t={t}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => false}
|
||||
saveModelCompatFlags={noopSync}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
containers.length = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PassthroughModelRow inline-edit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PassthroughModelRow — inline alias edit", () => {
|
||||
it("clicking the alias span enters edit mode (shows input)", () => {
|
||||
const el = mountPassthrough({ alias: "my-alias" });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
expect(span).toBeTruthy();
|
||||
expect(el.querySelector("input")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(el.querySelector("input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Enter key calls onSetAlias with the new value", () => {
|
||||
const onSetAlias = vi.fn();
|
||||
const el = mountPassthrough({ alias: "old-alias", onSetAlias });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const input = el.querySelector("input") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "new-alias");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSetAlias).toHaveBeenCalledWith("new-alias");
|
||||
});
|
||||
|
||||
it("Escape cancels without calling onSetAlias", () => {
|
||||
const onSetAlias = vi.fn();
|
||||
const el = mountPassthrough({ alias: "old-alias", onSetAlias });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const input = el.querySelector("input") as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "something-else");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSetAlias).not.toHaveBeenCalled();
|
||||
expect(el.querySelector("input")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows placeholder text when no alias is set", () => {
|
||||
const el = mountPassthrough({ alias: null });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
// providerText uses fallback "Click to set alias" when key not found via t()
|
||||
expect(span?.textContent).toBeTruthy();
|
||||
expect(span?.textContent).not.toBe("");
|
||||
});
|
||||
|
||||
it("shows alias value when alias is set", () => {
|
||||
const el = mountPassthrough({ alias: "my-alias" });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
expect(span?.textContent).toContain("my-alias");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModelRow inline-edit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ModelRow — inline alias edit", () => {
|
||||
it("clicking the alias span enters edit mode (shows input)", () => {
|
||||
const el = mountModelRow({ alias: "my-alias" });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
expect(span).toBeTruthy();
|
||||
expect(el.querySelector("input[type=text]")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(el.querySelector("input[type=text]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Enter key calls onSetAlias with the new value", () => {
|
||||
const onSetAlias = vi.fn();
|
||||
const el = mountModelRow({ alias: "old-alias", onSetAlias });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const input = el.querySelector("input[type=text]") as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "new-alias");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSetAlias).toHaveBeenCalledWith("new-alias");
|
||||
});
|
||||
|
||||
it("Escape cancels without calling onSetAlias", () => {
|
||||
const onSetAlias = vi.fn();
|
||||
const el = mountModelRow({ alias: "old-alias", onSetAlias });
|
||||
const span = el.querySelector("span.cursor-pointer");
|
||||
|
||||
act(() => {
|
||||
span!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const input = el.querySelector("input[type=text]") as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "something-else");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSetAlias).not.toHaveBeenCalled();
|
||||
expect(el.querySelector("input[type=text]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Port of decolua/9router PR #889 (Fajar Hidayat <fajarhide@gmail.com>):
|
||||
// "Add model deselection functionality in ComboFormModal and ComboDetailPage".
|
||||
//
|
||||
// Upstream UX: clicking an already-added (highlighted) model in ModelSelectModal
|
||||
// should TOGGLE — invoke onDeselect instead of onSelect — and the modal must stay
|
||||
// open when keepOpenOnSelect so the user can add/remove several models in one
|
||||
// session. OmniRoute already had the visual highlight (addedModelValues) but no
|
||||
// deselect callback nor an opt-out for the auto-close — added here.
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ModelSelectModal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
showCombos={false}
|
||||
activeProviders={[{ provider: "openai" }]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {});
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function findModelButton(container: HTMLElement, label: string): HTMLButtonElement | null {
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
return (buttons.find((b) => (b.textContent || "").includes(label)) as HTMLButtonElement) || null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "/api/provider-nodes") {
|
||||
return { ok: true, json: async () => ({ nodes: [] }) };
|
||||
}
|
||||
if (url === "/api/provider-models") {
|
||||
return { ok: true, json: async () => ({ models: {} }) };
|
||||
}
|
||||
if (url === "/api/combos") {
|
||||
return { ok: true, json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelSelectModal — deselect / toggle behavior (upstream PR #889)", () => {
|
||||
it("calls onDeselect (not onSelect) when clicking a model already in addedModelValues", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDeselect = vi.fn();
|
||||
|
||||
// We don't know exactly which OpenAI model labels the system catalog ships at
|
||||
// any given time, so probe the rendered DOM for one and use its value.
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onDeselect,
|
||||
addedModelValues: [],
|
||||
});
|
||||
|
||||
// Find the first OpenAI model rendered (it must exist — openai is an active provider).
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton, "expected at least one openai model button to render").not.toBeNull();
|
||||
|
||||
const modelName = (firstModelButton!.textContent || "").trim();
|
||||
expect(modelName.length).toBeGreaterThan(0);
|
||||
|
||||
// First click — model is NOT yet added → must call onSelect, not onDeselect.
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
|
||||
// The handler we got back from onSelect should be a model object with .value.
|
||||
const selectedArg = onSelect.mock.calls[0][0];
|
||||
expect(selectedArg).toMatchObject({ value: expect.any(String) });
|
||||
});
|
||||
|
||||
it("toggles to onDeselect when the model is already in addedModelValues", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDeselect = vi.fn();
|
||||
|
||||
// Render once to discover the model value
|
||||
const probe = await renderModal({ onSelect: vi.fn(), onDeselect: vi.fn() });
|
||||
const probeButton = probe.container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(probeButton).not.toBeNull();
|
||||
// The on-click handler embeds the model value; trigger once to capture it.
|
||||
const tempSelect = vi.fn();
|
||||
const probe2 = await renderModal({ onSelect: tempSelect, addedModelValues: [] });
|
||||
const probeButton2 = probe2.container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
probeButton2!.click();
|
||||
});
|
||||
const capturedValue = tempSelect.mock.calls[0][0].value as string;
|
||||
expect(typeof capturedValue).toBe("string");
|
||||
expect(capturedValue.length).toBeGreaterThan(0);
|
||||
|
||||
// Now render with that value pre-added and click again — must invoke onDeselect.
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onDeselect,
|
||||
addedModelValues: [capturedValue],
|
||||
keepOpenOnSelect: true,
|
||||
});
|
||||
|
||||
// The already-added model now renders with the emerald/added class — look for ✓.
|
||||
const addedButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent || "").includes("✓")
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(addedButton, "expected an added (✓) button to render").toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
addedButton!.click();
|
||||
});
|
||||
|
||||
expect(onDeselect).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onDeselect.mock.calls[0][0]).toMatchObject({ value: capturedValue });
|
||||
});
|
||||
|
||||
it("does NOT auto-close the modal when keepOpenOnSelect is true", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onClose,
|
||||
keepOpenOnSelect: true,
|
||||
});
|
||||
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still auto-closes by default (keepOpenOnSelect defaults to false, backward-compat)", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = await renderModal({ onSelect, onClose });
|
||||
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression coverage for the "keepOpenOnSelect" prop added in feat/port-pr-1031.
|
||||
// Mirrors the UX of upstream PR decolua/9router#1031: when a caller (e.g. combo
|
||||
// creation) opts out of the auto-close-on-select behaviour, the modal renders a
|
||||
// "Done" button in the footer so the user has a clear way to confirm they are
|
||||
// finished adding entries.
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ModelSelectModal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
showCombos={false}
|
||||
activeProviders={[]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "/api/provider-nodes") {
|
||||
return { ok: true, json: async () => ({ nodes: [] }) };
|
||||
}
|
||||
if (url === "/api/provider-models") {
|
||||
return { ok: true, json: async () => ({ models: {} }) };
|
||||
}
|
||||
if (url === "/api/combos") {
|
||||
return { ok: true, json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelSelectModal keepOpenOnSelect", () => {
|
||||
it("does not render the Done button by default (auto-close behaviour preserved)", async () => {
|
||||
const container = await renderModal();
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders the Done button when keepOpenOnSelect is true", async () => {
|
||||
const container = await renderModal({ keepOpenOnSelect: true });
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking Done triggers onClose without invoking onSelect again", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const container = await renderModal({ keepOpenOnSelect: true, onClose, onSelect });
|
||||
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
doneButton!.click();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render the Done button when multiSelect is true (multiSelect owns its own footer)", async () => {
|
||||
// multiSelect already ships a Clear + Done footer driven by selectedModels.
|
||||
// keepOpenOnSelect must defer to it to avoid two competing Done buttons.
|
||||
const container = await renderModal({ keepOpenOnSelect: true, multiSelect: true });
|
||||
const doneButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
// Exactly one Done button — the one inside the multiSelect footer, not a duplicate.
|
||||
expect(doneButtons.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ModelSelectModal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
showCombos={false}
|
||||
activeProviders={[]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "/api/provider-nodes") {
|
||||
return { ok: true, json: async () => ({ nodes: [] }) };
|
||||
}
|
||||
if (url === "/api/provider-models") {
|
||||
return { ok: true, json: async () => ({ models: {} }) };
|
||||
}
|
||||
if (url === "/api/combos") {
|
||||
return { ok: true, json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelSelectModal zero-config providers", () => {
|
||||
it("shows OpenCode Free models when explicitly included without an active connection", async () => {
|
||||
const container = await renderModal({ alwaysIncludeProviders: ["opencode"] });
|
||||
|
||||
expect(container.textContent).toContain("OpenCode Free");
|
||||
expect(container.textContent).toContain("Big Pickle");
|
||||
});
|
||||
|
||||
it("does not show OpenCode Free by default without an active connection", async () => {
|
||||
const container = await renderModal();
|
||||
|
||||
expect(container.textContent).not.toContain("OpenCode Free");
|
||||
expect(container.textContent).not.toContain("Big Pickle");
|
||||
});
|
||||
|
||||
it("treats null explicit provider lists as empty", async () => {
|
||||
const container = await renderModal({ alwaysIncludeProviders: null });
|
||||
|
||||
expect(container.textContent).not.toContain("OpenCode Free");
|
||||
expect(container.textContent).not.toContain("Big Pickle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
const { ModelVisibilityToolbar } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow"
|
||||
);
|
||||
|
||||
// providerText() falls back to the English string when t.has(key) is false.
|
||||
const t: any = Object.assign((k: string) => k, { has: () => false });
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(extra: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelVisibilityToolbar
|
||||
t={t}
|
||||
filterValue=""
|
||||
onFilterChange={() => {}}
|
||||
activeCount={0}
|
||||
totalCount={0}
|
||||
onSelectAll={() => {}}
|
||||
onDeselectAll={() => {}}
|
||||
{...(extra as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
const byText = (el: HTMLElement, text: string) =>
|
||||
Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.trim() === text);
|
||||
|
||||
const byTextIncludes = (el: HTMLElement, text: string) =>
|
||||
Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("ModelVisibilityToolbar — free filter & sort", () => {
|
||||
it("does not render free-filter or sort controls when their props are absent", () => {
|
||||
const el = render({});
|
||||
expect(byText(el, "Free only")).toBeUndefined();
|
||||
expect(byTextIncludes(el, "Free first")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders All / Free only / Paid only when freeFilter props are provided", () => {
|
||||
const el = render({ freeFilter: "all", onFreeFilterChange: () => {} });
|
||||
expect(byText(el, "Free only")).toBeTruthy();
|
||||
expect(byText(el, "Paid only")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onFreeFilterChange with the chosen filter", () => {
|
||||
const onFreeFilterChange = vi.fn();
|
||||
const el = render({ freeFilter: "all", onFreeFilterChange });
|
||||
act(() => {
|
||||
byText(el, "Free only")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onFreeFilterChange).toHaveBeenCalledWith("free");
|
||||
act(() => {
|
||||
byText(el, "Paid only")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onFreeFilterChange).toHaveBeenCalledWith("paid");
|
||||
});
|
||||
|
||||
it("toggles sort free-first", () => {
|
||||
const onSortFreeFirstChange = vi.fn();
|
||||
const el = render({ sortFreeFirst: false, onSortFreeFirstChange });
|
||||
const btn = byTextIncludes(el, "Free first")!;
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
act(() => {
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onSortFreeFirstChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function combo(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} desc`,
|
||||
pipeline: [{ engine: "rtk", intensity: "standard" }],
|
||||
languagePacks: ["en"],
|
||||
outputMode: false,
|
||||
outputModeIntensity: "full",
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setupFetchMock() {
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const combos = [combo("c1", "Alpha"), combo("c2", "Bravo")];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
void init;
|
||||
if (url.includes("/api/context/combos/") && url.includes("/assignments")) return json({ assignments: [] });
|
||||
if (url.includes("/api/context/combos")) return json({ combos });
|
||||
if (url.includes("/api/combos")) return json({ combos: [] });
|
||||
if (url.includes("/api/compression/language-packs")) return json({ packs: [] });
|
||||
if (url.includes("/api/settings/compression")) return json({ activeComboId: "c2", enabled: true });
|
||||
return json({}, 404);
|
||||
});
|
||||
}
|
||||
|
||||
describe("NamedCombosManager — active badge, no set-as-default", () => {
|
||||
async function render() {
|
||||
const { default: CompressionCombosPageClient } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionCombosPageClient />);
|
||||
});
|
||||
await flush();
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders no 'Set as default' button", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.textContent).not.toContain("Set as default");
|
||||
});
|
||||
|
||||
it("shows the '● Active' badge only on the combo whose id === activeComboId", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.querySelector('[data-testid="active-badge-c2"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="active-badge-c1"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ── Import component (no module-level mocks needed) ──────────────────────────
|
||||
|
||||
const { default: NoAuthAccountCard } = await import(
|
||||
"../../../src/shared/components/NoAuthAccountCard"
|
||||
);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_ID = "mimocode";
|
||||
|
||||
function makeFingerprints(n: number): string[] {
|
||||
// Deterministic, distinguishable, > 10 chars each so the truncation is exercised.
|
||||
return Array.from({ length: n }, (_, i) => `fp${String(i).padStart(2, "0")}deadbeefcafe`);
|
||||
}
|
||||
|
||||
function setupFetch(fingerprints: string[]) {
|
||||
const connections =
|
||||
fingerprints.length > 0
|
||||
? [
|
||||
{
|
||||
id: "conn-1",
|
||||
provider: PROVIDER_ID,
|
||||
providerSpecificData: { fingerprints, accountProxies: [] },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const mockFetch = vi.fn((url: string) => {
|
||||
if (String(url).includes("/api/providers")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ connections }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderCard() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
let counter = 0;
|
||||
act(() => {
|
||||
root.render(
|
||||
<NoAuthAccountCard
|
||||
providerId={PROVIDER_ID}
|
||||
providerName="MiMoCode"
|
||||
generateAccountId={() => `gen-${counter++}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
async function waitForCondition(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
const grid = (el: HTMLElement) =>
|
||||
el.querySelector<HTMLElement>("[data-testid='noauth-account-grid']");
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NoAuthAccountCard compact grid", () => {
|
||||
it("renders the account list as a multi-column grid container", async () => {
|
||||
setupFetch(makeFingerprints(15));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el) !== null);
|
||||
const container = grid(el)!;
|
||||
// Must be a responsive grid (not the old single-column stack) so 15 accounts stay compact.
|
||||
expect(container.className).toContain("grid");
|
||||
expect(container.className).toMatch(/sm:grid-cols-2|lg:grid-cols-3/);
|
||||
});
|
||||
|
||||
it("renders one chip per account fingerprint", async () => {
|
||||
setupFetch(makeFingerprints(15));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 15);
|
||||
expect(grid(el)!.querySelectorAll("[data-account-id]").length).toBe(15);
|
||||
});
|
||||
|
||||
it("constrains height with vertical scroll so a long list never grows unbounded", async () => {
|
||||
setupFetch(makeFingerprints(15));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el) !== null);
|
||||
const container = grid(el)!;
|
||||
expect(container.className).toContain("overflow-y-auto");
|
||||
expect(container.className).toMatch(/max-h-/);
|
||||
});
|
||||
|
||||
it("keeps a proxy control on every chip (shield icon)", async () => {
|
||||
setupFetch(makeFingerprints(3));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 3);
|
||||
const chips = Array.from(grid(el)!.querySelectorAll<HTMLElement>("[data-account-id]"));
|
||||
for (const chip of chips) {
|
||||
const proxyBtn = chip.querySelector("button[title]");
|
||||
expect(proxyBtn).toBeTruthy();
|
||||
expect(proxyBtn!.textContent).toContain("shield");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the proxy editor when a chip's shield is clicked", async () => {
|
||||
setupFetch(makeFingerprints(3));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 3);
|
||||
const firstChip = grid(el)!.querySelector<HTMLElement>("[data-account-id]")!;
|
||||
const proxyBtn = firstChip.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => {
|
||||
proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
expect(el.textContent).toContain("Proxy for Account 1");
|
||||
// No saved proxies in this fixture → editor falls back to the custom inputs.
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── #5217 (Gap 1): Proxy Pool dropdown (by-id reference) ──────────────────────
|
||||
|
||||
const SAVED_PROXIES = [
|
||||
{ id: "pool-1", name: "US East", type: "socks5", host: "1.2.3.4", port: 1080, status: "active" },
|
||||
{ id: "pool-2", name: "EU West", type: "http", host: "9.9.9.9", port: 8080, status: "active" },
|
||||
];
|
||||
|
||||
function setupFetchWithProxies(fingerprints: string[], accountProxies: unknown[] = []) {
|
||||
const connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
provider: PROVIDER_ID,
|
||||
providerSpecificData: { fingerprints, accountProxies },
|
||||
},
|
||||
];
|
||||
const putBodies: unknown[] = [];
|
||||
const mockFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/settings/proxies")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: SAVED_PROXIES }),
|
||||
} as Response);
|
||||
}
|
||||
if (u.includes("/api/providers")) {
|
||||
if (init?.method === "PUT" && typeof init.body === "string") {
|
||||
putBodies.push(JSON.parse(init.body));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ connections }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
return { mockFetch, putBodies };
|
||||
}
|
||||
|
||||
describe("NoAuthAccountCard proxy pool dropdown (#5217 Gap 1)", () => {
|
||||
it("defaults the editor to the Saved Proxy Pool dropdown when pool proxies exist", async () => {
|
||||
setupFetchWithProxies(makeFingerprints(2));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
// The saved dropdown lists every pool proxy by name; the manual Host input is hidden.
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select).toBeTruthy();
|
||||
expect(el.textContent).toContain("US East");
|
||||
expect(el.textContent).toContain("EU West");
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a by-id reference {fingerprint, proxyId} when a pool proxy is selected", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
const { putBodies } = setupFetchWithProxies(fps);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.querySelector("select") !== null);
|
||||
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(select, "pool-2");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
await waitForCondition(() => putBodies.length > 0);
|
||||
const body = putBodies.at(-1) as { providerSpecificData?: { accountProxies?: unknown[] } };
|
||||
const stored = body.providerSpecificData?.accountProxies ?? [];
|
||||
expect(stored).toEqual([{ fingerprint: fps[0], proxyId: "pool-2" }]);
|
||||
});
|
||||
|
||||
it("lights the shield for an account stored as a by-id reference", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
setupFetchWithProxies(fps, [{ fingerprint: fps[0], proxyId: "pool-1" }]);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const firstShield = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
// Tooltip is resolved from the referenced pool record, not an inline proxy.
|
||||
expect(firstShield.getAttribute("title")).toContain("1.2.3.4");
|
||||
expect(firstShield.className).toContain("text-blue-400");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock next-intl translations (the page imports useTranslations("auth")).
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import CallbackPage from "@/app/callback/page";
|
||||
|
||||
/**
|
||||
* Regression guard for ported upstream PR decolua/9router#998 (security):
|
||||
* the OAuth callback page must never relay {code, state} to a wildcard
|
||||
* postMessage target ("*"), as a hostile opener can read the code/state and
|
||||
* complete the OAuth flow as the user. Only the same-origin parent and
|
||||
* Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
|
||||
*/
|
||||
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let postMessageSpy: ReturnType<typeof vi.fn>;
|
||||
let originalOpener: typeof window.opener;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
postMessageSpy = vi.fn();
|
||||
originalOpener = window.opener;
|
||||
|
||||
// Set the callback URL with OAuth params (triggers the postMessage send).
|
||||
window.history.replaceState({}, "", "/callback?code=test_code_abc123&state=test_state_xyz789");
|
||||
|
||||
// Stub window.opener as a CROSS-ORIGIN opener: same-origin probe must throw
|
||||
// (mimics a real cross-origin window.opener), which means the page falls into
|
||||
// the fallback path that previously used a wildcard "*" target origin.
|
||||
Object.defineProperty(window, "opener", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
postMessage: postMessageSpy,
|
||||
get location(): never {
|
||||
throw new Error("cross-origin access blocked");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
Object.defineProperty(window, "opener", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalOpener,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("never targets the wildcard '*' origin even when opener is cross-origin", async () => {
|
||||
await act(async () => {
|
||||
root.render(<CallbackPage />);
|
||||
});
|
||||
// Give useEffect a microtask to flush.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
|
||||
expect(targetOrigins).not.toContain("*");
|
||||
});
|
||||
|
||||
it("only targets trusted origins (same-origin + Codex 127.0.0.1:1455)", async () => {
|
||||
await act(async () => {
|
||||
root.render(<CallbackPage />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
|
||||
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
|
||||
expect(targetOrigins.length).toBeGreaterThan(0);
|
||||
for (const origin of targetOrigins) {
|
||||
expect(trusted.has(origin)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers the OAuth code/state payload at least once via postMessage", async () => {
|
||||
await act(async () => {
|
||||
root.render(<CallbackPage />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Sanity: the scoped postMessage path still actually attempts delivery.
|
||||
expect(postMessageSpy).toHaveBeenCalled();
|
||||
const firstCall = postMessageSpy.mock.calls[0];
|
||||
expect(firstCall[0]).toMatchObject({
|
||||
type: "oauth_callback",
|
||||
data: expect.objectContaining({
|
||||
code: "test_code_abc123",
|
||||
state: "test_state_xyz789",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user