test(ui): coverage for fix4 UI behaviors (3 specs)

This commit is contained in:
diegosouzapw
2026-05-28 12:58:51 -03:00
parent cd46090b75
commit 5377f6f0c4
3 changed files with 534 additions and 0 deletions

View 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);
});

View 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);
});
});

View 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);
});
});