merge: F5 ComplianceTab actor filter + CompressionLogTab namespace fix

This commit is contained in:
diegosouzapw
2026-05-27 22:23:31 -03:00
6 changed files with 322 additions and 7 deletions

View File

@@ -72,6 +72,7 @@ export default function ComplianceTab() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [eventType, setEventType] = useState("");
const [actor, setActor] = useState("");
const [severity, setSeverity] = useState<"all" | Severity>("all");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
@@ -85,6 +86,7 @@ export default function ComplianceTab() {
try {
const params = new URLSearchParams();
if (actor) params.set("actor", actor);
params.set("limit", String(PAGE_SIZE));
params.set("offset", String(offset));
if (eventType) params.set("action", eventType);
@@ -105,7 +107,7 @@ export default function ComplianceTab() {
} finally {
setLoading(false);
}
}, [eventType, from, offset, t, to]);
}, [actor, eventType, from, offset, t, to]);
useEffect(() => {
void fetchEntries();
@@ -120,10 +122,15 @@ export default function ComplianceTab() {
return Array.from(new Set(entries.map((entry) => entry.action).filter(Boolean))).sort();
}, [entries]);
const actors = useMemo(() => {
return Array.from(new Set(entries.map((entry) => entry.actor).filter(Boolean))).sort();
}, [entries]);
const canGoNext = offset + PAGE_SIZE < totalCount;
const resetFilters = () => {
setEventType("");
setActor("");
setSeverity("all");
setFrom("");
setTo("");
@@ -186,7 +193,7 @@ export default function ComplianceTab() {
</Card>
<Card className="p-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("eventType")}
@@ -207,6 +214,26 @@ export default function ComplianceTab() {
))}
</datalist>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("actor")}
</span>
<input
list="compliance-actors"
value={actor}
onChange={(event) => {
setOffset(0);
setActor(event.target.value);
}}
placeholder={t("actorPlaceholder")}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<datalist id="compliance-actors">
{actors.map((a) => (
<option key={a} value={a} />
))}
</datalist>
</label>
<label className="space-y-1">
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
{t("severity")}

View File

@@ -24,7 +24,7 @@ interface LogEntry {
}
export default function CompressionLogTab() {
const t = useTranslations("settings");
const t = useTranslations("logs");
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -1137,7 +1137,9 @@
"a2aEvents": "Events",
"a2aArtifacts": "Artifacts",
"a2aNoTasks": "No A2A tasks recorded.",
"a2aLoadingTasks": "Loading A2A tasks..."
"a2aLoadingTasks": "Loading A2A tasks...",
"actor": "Actor",
"actorPlaceholder": "Filter by actor"
},
"themesPage": {
"title": "Themes",
@@ -3357,7 +3359,10 @@
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
}
},
"compressionLogTitle": "Compression Log",
"compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
"tokens": "tokens"
},
"onboarding": {
"welcome": "Welcome",

View File

@@ -1137,7 +1137,9 @@
"a2aEvents": "Eventos",
"a2aArtifacts": "Artefatos",
"a2aNoTasks": "Nenhuma tarefa A2A registrada.",
"a2aLoadingTasks": "Carregando tarefas A2A..."
"a2aLoadingTasks": "Carregando tarefas A2A...",
"actor": "Autor",
"actorPlaceholder": "Filtrar por autor"
},
"themesPage": {
"title": "Themes",
@@ -3354,7 +3356,10 @@
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
"copyLogEntry": "Copy log entry"
}
},
"compressionLogTitle": "Compression Log",
"compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
"tokens": "tokens"
},
"onboarding": {
"welcome": "Bem-vindo",

View File

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

View File

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