vitests for GuardrailViewer components

This commit is contained in:
= 2025-09-18 16:57:22 -07:00
parent 78cb8c71fd
commit f80660acd9
10 changed files with 2677 additions and 7 deletions

View File

@ -2879,7 +2879,34 @@ jobs:
name: Install Playwright Browsers
command: |
npx playwright install
- run:
name: Run UI unit tests (Vitest)
command: |
# Use the same Node version we installed earlier with nvm
export NVM_DIR="/opt/circleci/.nvm"
source "$NVM_DIR/nvm.sh"
nvm use v18.17.0
cd ui/litellm-dashboard
# Ensure clean, deterministic install (skip if you prefer npm install)
npm ci || npm install
# Run Vitest via your package.json script:
# - --run to avoid watch mode on CI
# - --coverage to produce coverage
# - --coverage.reporter=lcov so Codecov can ingest lcov.info
# - Feel free to drop --reporter if you dont need extra output
npm run test -- --run --coverage --coverage.reporter=lcov
# If you use a JUnit reporter (e.g., vitest-junit-reporter),
# ensure it writes to ./test-results/junit.xml so Circle collects it
mkdir -p test-results || true
- store_test_results:
path: ui/litellm-dashboard/test-results
- store_artifacts:
path: ui/litellm-dashboard/coverage
destination: ui-coverage
- run:
name: Build Docker image
command: docker build -t my-app:latest -f ./docker/Dockerfile.database .

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest",
"test:watch": "vitest -w"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.54.0",
@ -40,6 +42,9 @@
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.15",
"@types/node": "^20",
"@types/react": "18.2.48",
@ -50,10 +55,13 @@
"autoprefixer": "^10.4.17",
"eslint": "^8",
"eslint-config-next": "14.2.32",
"jsdom": "^27.0.0",
"postcss": "^8.4.33",
"prettier": "3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "5.3.3"
"typescript": "5.3.3",
"vite": "^5.4.20",
"vitest": "^1.6.1"
},
"overrides": {
"prismjs": ">=1.30.0",

View File

@ -0,0 +1,118 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import BedrockGuardrailDetails, {
BedrockGuardrailResponse,
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import {
makeAssessment,
makeBedrockCoverage,
makeBedrockResponse,
makeBedrockUsage,
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
describe('BedrockGuardrailDetails', () => {
it('returns null when response is falsy', () => {
// @ts-expect-error testing nullish handling
const { container } = renderWithProviders(<BedrockGuardrailDetails response={undefined} />);
expect(container).toBeEmptyDOMElement();
});
it('renders top summary: action chip, reason, blocked response', () => {
const resp: BedrockGuardrailResponse = makeBedrockResponse({
action: 'GUARDRAIL_INTERVENED',
actionReason: 'Policy violation',
blockedResponse: '[blocked]',
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('Action:')).toBeInTheDocument();
expect(screen.getByText('Policy violation')).toBeInTheDocument();
expect(screen.getByText('[blocked]')).toBeInTheDocument();
});
it('renders coverage and usage pills', () => {
const resp = makeBedrockResponse({
guardrailCoverage: makeBedrockCoverage(),
usage: makeBedrockUsage({ contentPolicyUnits: 7, wordPolicyUnits: 1 }),
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText(/text guarded 27\/100/)).toBeInTheDocument();
expect(screen.getByText(/images guarded 1\/3/)).toBeInTheDocument();
expect(screen.getByText(/contentPolicyUnits: 7/)).toBeInTheDocument();
expect(screen.getByText(/wordPolicyUnits: 1/)).toBeInTheDocument();
});
it('renders outputs when present (prefers `outputs`, falls back to `output`)', () => {
// Using outputs
let resp = makeBedrockResponse({ outputs: [{ text: 'hello' }] });
const { rerender } = renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('Outputs')).toBeInTheDocument();
expect(screen.getByText('hello')).toBeInTheDocument();
// Using output
resp = makeBedrockResponse({ outputs: undefined, output: [{ text: 'world' }] });
rerender(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('world')).toBeInTheDocument();
});
it('renders assessments with all policy sections and metrics', () => {
const resp = makeBedrockResponse({
assessments: [makeAssessment()],
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
// Assessment section present
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
// Word policy sections
expect(screen.getByText('Word Policy')).toBeInTheDocument();
expect(screen.getByText('Custom Words')).toBeInTheDocument();
expect(screen.getByText('Managed Word Lists')).toBeInTheDocument();
// Contextual grounding table headers
expect(screen.getByText('Contextual Grounding')).toBeInTheDocument();
expect(screen.getAllByText('Score').length).toBeGreaterThan(0);
expect(screen.getAllByText('Threshold').length).toBeGreaterThan(0);
// Sensitive Info sections
expect(screen.getByText('Sensitive Information')).toBeInTheDocument();
expect(screen.getByText('PII Entities')).toBeInTheDocument();
expect(screen.getByText('Custom Regexes')).toBeInTheDocument();
// Topic Policy
expect(screen.getByText('Topic Policy')).toBeInTheDocument();
expect(screen.getByText('weapons')).toBeInTheDocument();
// Invocation Metrics
expect(screen.getByText('Invocation Metrics')).toBeInTheDocument();
// Raw JSON section exists (closed by default)
expect(screen.getByText('Raw Bedrock Guardrail Response')).toBeInTheDocument();
});
it('handles non-text outputs gracefully', () => {
const resp = makeBedrockResponse({ outputs: [{}, { text: 'texty' }] });
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
expect(screen.getByText('(non-text output)')).toBeInTheDocument();
expect(screen.getByText('texty')).toBeInTheDocument();
});
it('gracefully handles missing optional sections', () => {
const resp = makeBedrockResponse({
assessments: [
{
// only include minimal fields; others omitted
invocationMetrics: { guardrailProcessingLatency: 5 },
} as any,
],
usage: undefined,
guardrailCoverage: undefined,
outputs: [],
});
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
// No crash, minimal render: Assessment + Invocation Metrics present, but no usage/coverage chips at top
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,154 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import {
makeBedrockResponse, makeEntity,
makeGuardrailInformation,
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
// We will mock child components selectively for some tests to assert prop passthrough,
// but also run an integration-style render without mocks.
const PresidioPath = '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
const BedrockPath = '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
describe('GuardrailViewer', () => {
beforeEach(() => {
vi.resetModules();
});
it('shows header, status pill color, duration rounding, and time labels', () => {
const data = makeGuardrailInformation({ duration: 1.23456, guardrail_status: 'success' });
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
// header status pill (success => green)
const statusBadges = screen.getAllByText('success');
// there are two status locations: header chip and grid "Status"
expect(statusBadges.length).toBeGreaterThanOrEqual(1);
// Quick class assertion for at least one of them
expect(statusBadges[0].className).toMatch(/bg-green-100/);
// duration displays with 4 decimals
expect(screen.getByText(/1\.2346s/)).toBeInTheDocument();
// time labels exist
expect(screen.getByText('Start Time:')).toBeInTheDocument();
expect(screen.getByText('End Time:')).toBeInTheDocument();
});
it('calculates and displays masked entity totals with pluralization', () => {
const data = makeGuardrailInformation({
masked_entity_count: { EMAIL_ADDRESS: 2, PHONE_NUMBER: 1 },
});
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.getByText('3 masked entities')).toBeInTheDocument();
// summary chips for each entry
expect(screen.getByText('EMAIL_ADDRESS: 2')).toBeInTheDocument();
expect(screen.getByText('PHONE_NUMBER: 1')).toBeInTheDocument();
});
it('hides masked badge & summary when count is zero/empty', () => {
const data = makeGuardrailInformation({ masked_entity_count: {} });
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.queryByText(/masked entity/)).not.toBeInTheDocument();
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
});
it('toggles main section open/closed and chevron rotation class', async () => {
const user = userEvent.setup();
const data = makeGuardrailInformation();
renderWithProviders(<GuardrailViewer data={data} />);
const header = screen.getByText('Guardrail Information').closest('div')!;
// Initially expanded
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
// Click to collapse
await user.click(header);
expect(screen.getByText('Click to expand')).toBeInTheDocument();
// Details gone
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
// Click to expand again
await user.click(header);
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
});
it('defaults to presidio provider when guardrail_provider is undefined', async () => {
vi.doMock(PresidioPath, () => ({
__esModule: true,
default: ({ entities }: any) => <div data-testid="presidio-mock">presidio {entities?.length}</div>,
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: undefined,
guardrail_response: [makeEntity(), makeEntity()],
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('presidio 2');
});
it('renders PresidioDetectedEntities when provider="presidio" and response has entities', async () => {
vi.doMock(PresidioPath, () => ({
__esModule: true,
default: ({ entities }: any) => <div data-testid="presidio-mock">count:{entities?.length}</div>,
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: 'presidio',
guardrail_response: [makeEntity()],
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('count:1');
});
it('renders BedrockGuardrailDetails when provider="bedrock"', async () => {
vi.doMock(BedrockPath, () => ({
__esModule: true,
default: ({ response }: any) => (
<div data-testid="bedrock-mock">{response?.action ?? 'no-action'}</div>
),
}));
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
const data = makeGuardrailInformation({
guardrail_provider: 'bedrock',
guardrail_response: makeBedrockResponse({ action: 'GUARDRAIL_INTERVENED' }),
});
renderWithProviders(<Component data={data} />);
expect(screen.getByTestId('bedrock-mock')).toHaveTextContent('GUARDRAIL_INTERVENED');
});
it('unknown provider renders neither Presidio nor Bedrock details', () => {
const data = makeGuardrailInformation({
guardrail_provider: 'unknown',
});
renderWithProviders(<GuardrailViewer data={data} />);
// Summary still present
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
// No provider sections
expect(screen.queryByText(/Detected Entities/)).not.toBeInTheDocument();
expect(screen.queryByText(/Raw Bedrock Guardrail Response/)).not.toBeInTheDocument();
});
it('integration: renders with real Bedrock details without mocks', () => {
const data = makeGuardrailInformation({
guardrail_provider: 'bedrock',
guardrail_response: makeBedrockResponse({
action: 'NONE',
outputs: [{ text: 'ok' }],
}),
});
renderWithProviders(<GuardrailViewer data={data} />);
// Bedrock summary bits
expect(screen.getByText('Outputs')).toBeInTheDocument();
expect(screen.getByText('ok')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,55 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import PresidioDetectedEntities from '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
import { renderWithProviders, screen } from "../../../../tests/test-utils"
import { makeEntity } from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
describe('PresidioDetectedEntities', () => {
it('renders null when entities empty', () => {
const { container } = renderWithProviders(<PresidioDetectedEntities entities={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('renders per-entity header info including score color and position', async () => {
const user = userEvent.setup();
const e = makeEntity({ start: 10, end: 20, score: 0.92, entity_type: 'EMAIL_ADDRESS' });
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
// Header row values
expect(screen.getByText('EMAIL_ADDRESS')).toBeInTheDocument();
expect(screen.getByText(/Score: 0\.92/)).toBeInTheDocument();
expect(screen.getByText('Position: 10-20')).toBeInTheDocument();
// Expand details
await user.click(screen.getByText('EMAIL_ADDRESS'));
expect(screen.getByText('Entity Type:')).toBeInTheDocument();
expect(screen.getByText('Characters 10-20')).toBeInTheDocument();
expect(screen.getByText('Confidence:')).toBeInTheDocument();
// Recognizer details
expect(screen.getByText('EmailRecognizer')).toBeInTheDocument();
expect(screen.getByText('email_v1')).toBeInTheDocument();
// Explanation
expect(screen.getByText('Matched via pattern')).toBeInTheDocument();
});
it('handles missing metadata & low scores gracefully', async () => {
const user = userEvent.setup();
const e = makeEntity({
score: 0.3,
recognition_metadata: undefined as any,
analysis_explanation: null,
entity_type: 'NAME',
start: 0,
end: 0,
});
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
await user.click(screen.getByText('NAME'));
// No recognizer/explanation rows
expect(screen.queryByText('Recognizer:')).not.toBeInTheDocument();
expect(screen.queryByText('Explanation:')).not.toBeInTheDocument();
// Position still renders
expect(screen.getByText('Characters 0-0')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,119 @@
import type {
BedrockGuardrailResponse,
BedrockAssessment,
BedrockGuardrailCoverage,
BedrockGuardrailUsage,
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
export interface RecognitionMetadata {
recognizer_name: string;
recognizer_identifier: string;
}
export interface GuardrailEntity {
end: number;
score: number;
start: number;
entity_type: string;
analysis_explanation: string | null;
recognition_metadata: RecognitionMetadata;
}
export interface GuardrailInformation {
duration: number;
end_time: number;
start_time: number;
guardrail_mode: string;
guardrail_name: string;
guardrail_status: string;
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
masked_entity_count: Record<string, number>;
guardrail_provider?: string;
}
// ===== Builders =====
export const makeEntity = (overrides: Partial<GuardrailEntity> = {}): GuardrailEntity => ({
end: 18,
start: 5,
score: 0.92,
entity_type: 'EMAIL_ADDRESS',
analysis_explanation: 'Matched via pattern',
recognition_metadata: {
recognizer_name: 'EmailRecognizer',
recognizer_identifier: 'email_v1',
},
...overrides,
});
export const makeMaskedCounts = (overrides: Record<string, number> = {}) => ({
EMAIL_ADDRESS: 2,
PHONE_NUMBER: 1,
...overrides,
});
export const makeBedrockUsage = (overrides: Partial<BedrockGuardrailUsage> = {}): BedrockGuardrailUsage => ({
contentPolicyUnits: 4,
topicPolicyUnits: 2,
...overrides,
});
export const makeBedrockCoverage = (
overrides: Partial<BedrockGuardrailCoverage> = {}
): BedrockGuardrailCoverage => ({
textCharacters: { guarded: 27, total: 100 },
images: { guarded: 1, total: 3 },
...overrides,
});
export const makeAssessment = (overrides: Partial<BedrockAssessment> = {}): BedrockAssessment => ({
wordPolicy: {
customWords: [{ action: 'BLOCKED', detected: true, match: 'badword' }],
managedWordLists: [{ action: 'ALLOWED', detected: false, match: 'ok', type: 'PROFANITY' }],
},
contentPolicy: {
filters: [
{ type: 'HATE', action: 'BLOCKED', detected: true, filterStrength: 'HIGH', confidence: 'MEDIUM' },
{ type: 'VIOLENCE', action: 'NONE', detected: false, filterStrength: 'LOW', confidence: 'LOW' },
],
},
topicPolicy: { topics: [{ name: 'weapons', type: 'DENY', detected: true, action: 'BLOCKED' }] },
sensitiveInformationPolicy: {
piiEntities: [{ type: 'EMAIL', match: 'x@y.com', detected: true, action: 'ANONYMIZED' }],
regexes: [{ name: 'ticket', regex: '#[0-9]+', match: '#123', detected: true, action: 'BLOCKED' }],
},
contextualGroundingPolicy: {
filters: [{ type: 'GROUNDING', action: 'BLOCKED', detected: true, score: 0.2, threshold: 0.5 }],
},
automatedReasoningPolicy: { findings: [{ foo: 'bar' }] },
invocationMetrics: {
guardrailProcessingLatency: 42,
usage: makeBedrockUsage(),
guardrailCoverage: makeBedrockCoverage(),
},
...overrides,
});
export const makeBedrockResponse = (
overrides: Partial<BedrockGuardrailResponse> = {}
): BedrockGuardrailResponse => ({
action: 'NONE',
outputs: [{ text: 'ok' }],
usage: makeBedrockUsage(),
guardrailCoverage: makeBedrockCoverage(),
assessments: [makeAssessment()],
...overrides,
});
export const makeGuardrailInformation = (
overrides: Partial<GuardrailInformation> = {}
): GuardrailInformation => ({
guardrail_name: 'pii-rail',
guardrail_mode: 'post',
guardrail_status: 'success',
start_time: 1_700_000_000,
end_time: 1_700_000_123,
duration: 0.123456,
guardrail_response: [makeEntity()],
masked_entity_count: makeMaskedCounts(),
...overrides,
});

View File

@ -0,0 +1,15 @@
import '@testing-library/jest-dom';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
// Make toLocaleString deterministic in tests; individual tests can override
// This returns ISO-like strings to keep assertions stable.
vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ..._args: unknown[]) {
const d = this;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
});

View File

@ -0,0 +1,12 @@
import React, { PropsWithChildren } from 'react';
import { render, RenderOptions } from '@testing-library/react';
const Providers: React.FC<PropsWithChildren> = ({ children }) => {
// Add future providers here (Theme/Router/QueryClient/etc.)
return <>{children}</>;
};
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: Providers, ...options });
export * from '@testing-library/react';

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import { resolve } from "path"
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['tests/setupTests.ts'],
globals: true,
css: true, // lets you import CSS/modules without extra mocks
coverage: { reporter: ['text', 'lcov'] },
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})