mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
merge(fix4): historic banner + conversation separators + per-agent risk modal (Group A)
This commit is contained in:
@@ -6,10 +6,29 @@ import { AgentIcon } from "./shared/AgentIcon";
|
||||
import { DnsStatusBadge } from "./shared/DnsStatusBadge";
|
||||
import { ModelMappingTable } from "./ModelMappingTable";
|
||||
import { SetupWizard } from "./SetupWizard";
|
||||
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
const RISK_STORAGE_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
|
||||
|
||||
function hasAcceptedRisk(agentId: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(RISK_STORAGE_KEY_PREFIX + agentId) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markRiskAccepted(agentId: string): void {
|
||||
try {
|
||||
localStorage.setItem(RISK_STORAGE_KEY_PREFIX + agentId, "true");
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
target: MitmTarget;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
@@ -34,6 +53,7 @@ export function AgentCard({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [dnsLoading, setDnsLoading] = useState(false);
|
||||
const [riskModalOpen, setRiskModalOpen] = useState(false);
|
||||
|
||||
const dnsEnabled = agentState?.dns_enabled ?? false;
|
||||
const setupCompleted = agentState?.setup_completed ?? false;
|
||||
@@ -73,15 +93,30 @@ export function AgentCard({
|
||||
);
|
||||
};
|
||||
|
||||
const handleDnsToggle = async () => {
|
||||
const reallyToggleDns = async (enabled: boolean) => {
|
||||
setDnsLoading(true);
|
||||
try {
|
||||
await onDnsToggle(target.id, !dnsEnabled);
|
||||
await onDnsToggle(target.id, enabled);
|
||||
} finally {
|
||||
setDnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDnsToggle = async () => {
|
||||
const enabling = !dnsEnabled;
|
||||
if (enabling && !hasAcceptedRisk(target.id)) {
|
||||
setRiskModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await reallyToggleDns(enabling);
|
||||
};
|
||||
|
||||
const handleRiskAccept = async () => {
|
||||
markRiskAccepted(target.id);
|
||||
setRiskModalOpen(false);
|
||||
await reallyToggleDns(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -227,6 +262,15 @@ export function AgentCard({
|
||||
onDnsToggle={onDnsToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RiskNoticeModal
|
||||
open={riskModalOpen}
|
||||
title={t("riskNoticeTitle")}
|
||||
body={t("riskNoticeBody")}
|
||||
dontShowAgainKey={RISK_STORAGE_KEY_PREFIX + target.id}
|
||||
onAccept={handleRiskAccept}
|
||||
onCancel={() => setRiskModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CaptureModesToolbar } from "./components/CaptureModesToolbar";
|
||||
import { TopBarControls } from "./components/TopBarControls";
|
||||
import { RequestStreamingList } from "./components/RequestStreamingList";
|
||||
import { DetailsPanel } from "./components/DetailsPanel";
|
||||
import { HistoricSessionBanner } from "./components/session/HistoricSessionBanner";
|
||||
|
||||
const BUFFER_MAX = 1000;
|
||||
|
||||
@@ -87,6 +88,18 @@ export function TrafficInspectorPageClient() {
|
||||
<CaptureModesToolbar customHostCount={0} />
|
||||
</div>
|
||||
|
||||
{/* Historic session banner */}
|
||||
{filters.sessionId !== undefined && (
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<HistoricSessionBanner
|
||||
sessionName={
|
||||
recorder.sessions.find((s) => s.id === filters.sessionId)?.name ?? null
|
||||
}
|
||||
onBackToLive={() => setSessionId(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top bar filter/controls */}
|
||||
<div className="shrink-0">
|
||||
<TopBarControls
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HistoricSessionBannerProps {
|
||||
sessionName: string | null;
|
||||
onBackToLive: () => void;
|
||||
}
|
||||
|
||||
export function HistoricSessionBanner({ sessionName, onBackToLive }: HistoricSessionBannerProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
history
|
||||
</span>
|
||||
<span>
|
||||
{t("viewingRecordedSession")} —{" "}
|
||||
<strong>{sessionName ?? t("untitledSession")}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToLive}
|
||||
className="rounded border border-amber-500/40 px-2 py-0.5 text-xs hover:bg-amber-500/20 focus-ring"
|
||||
>
|
||||
{t("backToLive")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { normalizeConversation } from "@/mitm/inspector/conversationNormalizer";
|
||||
import { ChatBubble } from "../chat/ChatBubble";
|
||||
@@ -9,6 +10,7 @@ interface ConversationTabProps {
|
||||
}
|
||||
|
||||
export function ConversationTab({ request }: ConversationTabProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const conversation = normalizeConversation(request);
|
||||
|
||||
if (!conversation) {
|
||||
@@ -36,9 +38,30 @@ export function ConversationTab({ request }: ConversationTabProps) {
|
||||
<span className="font-mono text-blue-400">#{conversation.contextKey.slice(0, 12)}</span>
|
||||
</div>
|
||||
)}
|
||||
{allTurns.map((turn, i) => (
|
||||
<ChatBubble key={i} turn={turn} />
|
||||
))}
|
||||
{conversation.request.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
<span>{t("contextHistory")}</span>
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
</div>
|
||||
{conversation.request.map((turn, i) => (
|
||||
<ChatBubble key={`req-${i}`} turn={turn} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{conversation.response.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
<span>{t("modelResponse")}</span>
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
</div>
|
||||
{conversation.response.map((turn, i) => (
|
||||
<ChatBubble key={`res-${i}`} turn={turn} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7388,7 +7388,9 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"loading": "Loading…"
|
||||
"loading": "Loading…",
|
||||
"riskNoticeTitle": "Risk acknowledgment",
|
||||
"riskNoticeBody": "AgentBridge will intercept HTTPS traffic from this agent by redirecting its API hosts via DNS. Only enable if you accept responsibility for compliance with the agent's terms of service and any applicable network policies."
|
||||
},
|
||||
"trafficInspector": {
|
||||
"title": "Traffic Inspector",
|
||||
@@ -7457,6 +7459,11 @@
|
||||
"copy": "Copy",
|
||||
"httpProxyTitle": "HTTP Proxy Snippet — port {port}",
|
||||
"notRecording": "Not recording",
|
||||
"anyStatus": "Any status"
|
||||
"anyStatus": "Any status",
|
||||
"viewingRecordedSession": "Viewing recorded session",
|
||||
"backToLive": "Back to live",
|
||||
"untitledSession": "Untitled session",
|
||||
"contextHistory": "Context History",
|
||||
"modelResponse": "Model Response"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7378,7 +7378,9 @@
|
||||
"back": "Voltar",
|
||||
"next": "Próximo",
|
||||
"done": "Concluído",
|
||||
"loading": "Carregando…"
|
||||
"loading": "Carregando…",
|
||||
"riskNoticeTitle": "Reconhecimento de risco",
|
||||
"riskNoticeBody": "O AgentBridge irá interceptar o tráfego HTTPS deste agente redirecionando seus hosts de API via DNS. Ative somente se você aceita a responsabilidade pelo cumprimento dos termos de serviço do agente e das políticas de rede aplicáveis."
|
||||
},
|
||||
"trafficInspector": {
|
||||
"title": "Inspector de Tráfego",
|
||||
@@ -7447,6 +7449,11 @@
|
||||
"copy": "Copiar",
|
||||
"httpProxyTitle": "Snippet de Proxy HTTP — porta {port}",
|
||||
"notRecording": "Sem gravação",
|
||||
"anyStatus": "Qualquer status"
|
||||
"anyStatus": "Qualquer status",
|
||||
"viewingRecordedSession": "Visualizando sessão gravada",
|
||||
"backToLive": "Voltar ao live",
|
||||
"untitledSession": "Sessão sem nome",
|
||||
"contextHistory": "Histórico do Contexto",
|
||||
"modelResponse": "Resposta do Modelo"
|
||||
}
|
||||
}
|
||||
|
||||
316
tests/unit/ui/agent-card-risk-modal.test.tsx
Normal file
316
tests/unit/ui/agent-card-risk-modal.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
// @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),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/Button", () => ({
|
||||
Button: ({
|
||||
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("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);
|
||||
});
|
||||
141
tests/unit/ui/conversation-tab-separators.test.tsx
Normal file
141
tests/unit/ui/conversation-tab-separators.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
77
tests/unit/ui/historic-session-banner.test.tsx
Normal file
77
tests/unit/ui/historic-session-banner.test.tsx
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user