Improve test coverage
This commit is contained in:
parent
bacf6b5fc9
commit
0b717bfa44
14 changed files with 829 additions and 128 deletions
|
|
@ -2,23 +2,29 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: hashpw <password>")
|
||||
os.Exit(1)
|
||||
func run(args []string, out io.Writer) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(out, "Usage: hashpw <password>")
|
||||
return 1
|
||||
}
|
||||
|
||||
password := os.Args[1]
|
||||
password := args[1]
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
fmt.Fprintf(out, "Error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println(hash)
|
||||
fmt.Fprintln(out, hash)
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args, os.Stdout))
|
||||
}
|
||||
|
|
|
|||
36
cmd/hashpw/main_test.go
Normal file
36
cmd/hashpw/main_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
)
|
||||
|
||||
func TestRunUsage(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
code := run([]string{"hashpw"}, &out)
|
||||
if code != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", code)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Usage: hashpw <password>") {
|
||||
t.Fatalf("expected usage output, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHashesPassword(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
code := run([]string{"hashpw", "this-is-a-test-password"}, &out)
|
||||
if code != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (output: %q)", code, out.String())
|
||||
}
|
||||
|
||||
hash := strings.TrimSpace(out.String())
|
||||
if hash == "" {
|
||||
t.Fatalf("expected hash output, got empty string")
|
||||
}
|
||||
if !auth.CheckPasswordHash("this-is-a-test-password", hash) {
|
||||
t.Fatalf("expected hash to validate against original password")
|
||||
}
|
||||
}
|
||||
BIN
frontend-modern/package-lock.json
generated
BIN
frontend-modern/package-lock.json
generated
Binary file not shown.
|
|
@ -19,6 +19,8 @@
|
|||
"preview": "vite preview",
|
||||
"generate-types": "cd ../scripts && go run generate-types.go",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx",
|
||||
"test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
"@types/node": "^20.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
|
|
|
|||
130
frontend-modern/src/api/__tests__/ai.test.ts
Normal file
130
frontend-modern/src/api/__tests__/ai.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
describe('AIAPI', () => {
|
||||
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
|
||||
const apiFetchMock = vi.mocked(apiFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchJSONMock.mockReset();
|
||||
apiFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('calls the expected endpoints for settings and models', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any);
|
||||
await AIAPI.getSettings();
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai');
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any);
|
||||
await AIAPI.updateSettings({ enabled: true });
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai/update', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ models: [] } as any);
|
||||
await AIAPI.getModels();
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/models');
|
||||
});
|
||||
|
||||
it('includes query parameters for cost and intelligence endpoints', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
await AIAPI.getCostSummary();
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/cost/summary?days=30');
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
await AIAPI.getCostSummary(7);
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/cost/summary?days=7');
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
await AIAPI.getPatterns('vm:101');
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/patterns?resource_id=vm%3A101');
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
await AIAPI.getRecentChanges(12);
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/changes?hours=12');
|
||||
});
|
||||
|
||||
it('sanitizes runCommand payload consistently', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ output: 'ok', success: true } as any);
|
||||
await AIAPI.runCommand({
|
||||
command: 'echo hi',
|
||||
target_type: 'vm',
|
||||
target_id: 'vm-101',
|
||||
run_on_host: undefined as any,
|
||||
vmid: 101,
|
||||
target_host: 'delly',
|
||||
});
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/run-command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
command: 'echo hi',
|
||||
target_type: 'vm',
|
||||
target_id: 'vm-101',
|
||||
run_on_host: false,
|
||||
vmid: '101',
|
||||
target_host: 'delly',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws useful errors for investigateAlert failures', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(new Response('backend error', { status: 500 }));
|
||||
await expect(
|
||||
AIAPI.investigateAlert(
|
||||
{
|
||||
alert_id: 'a1',
|
||||
resource_id: 'r1',
|
||||
resource_name: 'res',
|
||||
resource_type: 'vm',
|
||||
alert_type: 'cpu',
|
||||
level: 'warning',
|
||||
value: 1,
|
||||
threshold: 2,
|
||||
message: 'msg',
|
||||
duration: '1m',
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
).rejects.toThrow('backend error');
|
||||
});
|
||||
|
||||
it('throws when investigateAlert has no streaming body', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
await expect(
|
||||
AIAPI.investigateAlert(
|
||||
{
|
||||
alert_id: 'a1',
|
||||
resource_id: 'r1',
|
||||
resource_name: 'res',
|
||||
resource_type: 'vm',
|
||||
alert_type: 'cpu',
|
||||
level: 'warning',
|
||||
value: 1,
|
||||
threshold: 2,
|
||||
message: 'msg',
|
||||
duration: '1m',
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
).rejects.toThrow('No response body');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ export class AIAPI {
|
|||
target_type: string;
|
||||
target_id: string;
|
||||
run_on_host: boolean;
|
||||
vmid?: string;
|
||||
vmid?: string | number;
|
||||
target_host?: string; // Explicit host for command routing
|
||||
}): Promise<{ output: string; success: boolean; error?: string }> {
|
||||
// Ensure run_on_host is explicitly a boolean (not undefined)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { Component, Show, createSignal, For, createEffect, createMemo, onMount, Switch, Match } from 'solid-js';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { GuestNotes } from './GuestNotes';
|
||||
import {
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
getGuestName,
|
||||
groupModelsByProvider,
|
||||
renderMarkdown,
|
||||
sanitizeThinking,
|
||||
} from './aiChatUtils';
|
||||
import type {
|
||||
AIToolExecution,
|
||||
AIStreamEvent,
|
||||
|
|
@ -17,115 +22,6 @@ import type {
|
|||
ModelInfo,
|
||||
} from '@/types/ai';
|
||||
|
||||
// Provider display names for grouped model selection
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
deepseek: 'DeepSeek',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
|
||||
// Parse provider from model ID (format: "provider:model-name")
|
||||
function getProviderFromModelId(modelId: string): string {
|
||||
const colonIndex = modelId.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return modelId.substring(0, colonIndex);
|
||||
}
|
||||
// Default detection for models without prefix
|
||||
if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (modelId.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
// Group models by provider for grouped rendering
|
||||
function groupModelsByProvider(models: ModelInfo[]): Map<string, ModelInfo[]> {
|
||||
const grouped = new Map<string, ModelInfo[]>();
|
||||
|
||||
for (const model of models) {
|
||||
const provider = getProviderFromModelId(model.id);
|
||||
const existing = grouped.get(provider) || [];
|
||||
existing.push(model);
|
||||
grouped.set(provider, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
});
|
||||
|
||||
let domPurifyConfigured = false;
|
||||
const configureDOMPurify = () => {
|
||||
if (domPurifyConfigured) return;
|
||||
domPurifyConfigured = true;
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const element = node as Element | null;
|
||||
if (!element || element.tagName !== 'A') return;
|
||||
element.setAttribute('target', '_blank');
|
||||
element.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to render markdown safely with XSS protection
|
||||
// LLM output should NEVER be trusted - always sanitize before rendering as HTML
|
||||
const renderMarkdown = (content: string): string => {
|
||||
try {
|
||||
configureDOMPurify();
|
||||
const rawHtml = marked.parse(content) as string;
|
||||
// Sanitize to prevent XSS from malicious LLM output or injected content
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
// Allow common formatting tags but block scripts, iframes, etc.
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'code', 'pre', 'blockquote',
|
||||
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'hr', 'table',
|
||||
'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||||
// Force all links to open in new tab and prevent opener attacks
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
});
|
||||
} catch {
|
||||
// If parsing fails, escape HTML entities as fallback
|
||||
return content.replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return entities[char] || char;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to sanitize thinking/reasoning content for display
|
||||
// Removes raw network errors with IP addresses that are not user-friendly
|
||||
const sanitizeThinking = (content: string): string => {
|
||||
// Replace raw TCP connection details like "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout"
|
||||
// with friendlier messages
|
||||
let sanitized = content.replace(
|
||||
/write tcp [\d.:]+->[\d.:]+: i\/o timeout/g,
|
||||
'connection timed out'
|
||||
);
|
||||
sanitized = sanitized.replace(
|
||||
/read tcp [\d.:]+: i\/o timeout/g,
|
||||
'connection timed out'
|
||||
);
|
||||
sanitized = sanitized.replace(
|
||||
/dial tcp [\d.:]+: connection refused/g,
|
||||
'connection refused'
|
||||
);
|
||||
// Replace "failed to send command: <raw error>" patterns
|
||||
sanitized = sanitized.replace(
|
||||
/failed to send command: write tcp [\d.:->\s]+/g,
|
||||
'failed to send command: connection error'
|
||||
);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// In-progress tool execution (before completion)
|
||||
interface PendingTool {
|
||||
|
|
@ -173,14 +69,6 @@ interface AIChatProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Extract guest name from context if available
|
||||
const getGuestName = (context?: Record<string, unknown>): string | undefined => {
|
||||
if (!context) return undefined;
|
||||
if (typeof context.guestName === 'string') return context.guestName;
|
||||
if (typeof context.name === 'string') return context.name;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const AIChat: Component<AIChatProps> = (props) => {
|
||||
// Read all context from store for proper SolidJS reactivity
|
||||
const isOpen = () => aiChatStore.isOpen;
|
||||
|
|
|
|||
104
frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts
Normal file
104
frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as utils from '@/components/AI/aiChatUtils';
|
||||
import { marked } from 'marked';
|
||||
import type { ModelInfo } from '@/types/ai';
|
||||
|
||||
describe('aiChatUtils', () => {
|
||||
describe('getProviderFromModelId', () => {
|
||||
it('uses explicit provider prefix when present', () => {
|
||||
expect(utils.getProviderFromModelId('openai:gpt-4o')).toBe('openai');
|
||||
expect(utils.getProviderFromModelId('anthropic:claude-3-5-sonnet')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects provider from known model naming', () => {
|
||||
expect(utils.getProviderFromModelId('claude-3-5-sonnet')).toBe('anthropic');
|
||||
expect(utils.getProviderFromModelId('o3-mini')).toBe('openai');
|
||||
expect(utils.getProviderFromModelId('deepseek-r1')).toBe('deepseek');
|
||||
expect(utils.getProviderFromModelId('llama3.1')).toBe('ollama');
|
||||
});
|
||||
|
||||
it('handles odd strings without a provider prefix', () => {
|
||||
// colon at index 0 should not be treated as a provider prefix
|
||||
expect(utils.getProviderFromModelId(':gpt-4o')).toBe('openai');
|
||||
expect(utils.getProviderFromModelId(':unknown-model')).toBe('ollama');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupModelsByProvider', () => {
|
||||
it('groups models by detected provider', () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ id: 'openai:gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'deepseek-r1', name: 'DeepSeek R1' },
|
||||
{ id: 'ollama:llama3.1', name: 'Llama 3.1' },
|
||||
];
|
||||
|
||||
const grouped = utils.groupModelsByProvider(models);
|
||||
expect(Array.from(grouped.keys()).sort()).toEqual(['anthropic', 'deepseek', 'ollama', 'openai']);
|
||||
expect(grouped.get('openai')?.map((m) => m.id)).toEqual(['openai:gpt-4o']);
|
||||
expect(grouped.get('anthropic')?.map((m) => m.id)).toEqual(['claude-3-5-sonnet']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeThinking', () => {
|
||||
it('replaces raw tcp timeout and connection errors', () => {
|
||||
const input = [
|
||||
'write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout',
|
||||
'read tcp 10.0.0.1: i/o timeout',
|
||||
'dial tcp 127.0.0.1: connection refused',
|
||||
'failed to send command: write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout',
|
||||
].join('\n');
|
||||
|
||||
const output = utils.sanitizeThinking(input);
|
||||
expect(output).toContain('connection timed out');
|
||||
expect(output).toContain('connection refused');
|
||||
expect(output).toContain('failed to send command: connection error');
|
||||
expect(output).not.toContain('192.168.0.123');
|
||||
expect(output).not.toContain('10.0.0.1');
|
||||
expect(output).not.toContain('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestName', () => {
|
||||
it('prefers guestName then falls back to name', () => {
|
||||
expect(utils.getGuestName(undefined)).toBeUndefined();
|
||||
expect(utils.getGuestName({})).toBeUndefined();
|
||||
expect(utils.getGuestName({ name: 'vm-101' })).toBe('vm-101');
|
||||
expect(utils.getGuestName({ guestName: 'container-202', name: 'ignored' })).toBe('container-202');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('sanitizes HTML and forces safe link attributes', () => {
|
||||
const output = utils.renderMarkdown(
|
||||
[
|
||||
'Hello',
|
||||
'',
|
||||
'[link](https://example.com)',
|
||||
'',
|
||||
'<script>alert("xss")</script>',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(output).toContain('Hello');
|
||||
expect(output).toContain('<a');
|
||||
expect(output).toContain('href="https://example.com"');
|
||||
expect(output).toContain('target="_blank"');
|
||||
expect(output).toContain('rel="noopener noreferrer"');
|
||||
expect(output).not.toContain('<script');
|
||||
expect(output).not.toContain('alert("xss")');
|
||||
});
|
||||
|
||||
it('escapes HTML entities if markdown parsing fails', () => {
|
||||
const spy = vi.spyOn(marked, 'parse').mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const output = utils.renderMarkdown(`a&b <c> "d" 'e'`);
|
||||
expect(output).toBe('a&b <c> "d" 'e'');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend-modern/src/components/AI/aiChatUtils.ts
Normal file
123
frontend-modern/src/components/AI/aiChatUtils.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { ModelInfo } from '@/types/ai';
|
||||
|
||||
// Provider display names for grouped model selection
|
||||
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
deepseek: 'DeepSeek',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
|
||||
// Parse provider from model ID (format: "provider:model-name")
|
||||
export function getProviderFromModelId(modelId: string): string {
|
||||
const colonIndex = modelId.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return modelId.substring(0, colonIndex);
|
||||
}
|
||||
// Default detection for models without prefix
|
||||
if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (modelId.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
// Group models by provider for grouped rendering
|
||||
export function groupModelsByProvider(models: ModelInfo[]): Map<string, ModelInfo[]> {
|
||||
const grouped = new Map<string, ModelInfo[]>();
|
||||
|
||||
for (const model of models) {
|
||||
const provider = getProviderFromModelId(model.id);
|
||||
const existing = grouped.get(provider) || [];
|
||||
existing.push(model);
|
||||
grouped.set(provider, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
});
|
||||
|
||||
let domPurifyConfigured = false;
|
||||
const configureDOMPurify = () => {
|
||||
if (domPurifyConfigured) return;
|
||||
domPurifyConfigured = true;
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const element = node as Element | null;
|
||||
if (!element || element.tagName !== 'A') return;
|
||||
element.setAttribute('target', '_blank');
|
||||
element.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to render markdown safely with XSS protection
|
||||
// LLM output should NEVER be trusted - always sanitize before rendering as HTML
|
||||
export const renderMarkdown = (content: string): string => {
|
||||
try {
|
||||
configureDOMPurify();
|
||||
const rawHtml = marked.parse(content) as string;
|
||||
// Sanitize to prevent XSS from malicious LLM output or injected content
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
// Allow common formatting tags but block scripts, iframes, etc.
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'code', 'pre', 'blockquote',
|
||||
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'hr', 'table',
|
||||
'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||||
// Force all links to open in new tab and prevent opener attacks
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
});
|
||||
} catch {
|
||||
// If parsing fails, escape HTML entities as fallback
|
||||
return content.replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return entities[char];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to sanitize thinking/reasoning content for display
|
||||
// Removes raw network errors with IP addresses that are not user-friendly
|
||||
export const sanitizeThinking = (content: string): string => {
|
||||
// Replace raw TCP connection details like "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout"
|
||||
// with friendlier messages
|
||||
// Replace "failed to send command: <raw error>" patterns first so follow-on replacements
|
||||
// don't partially sanitize the error and prevent the higher-level pattern from matching.
|
||||
let sanitized = content.replace(
|
||||
/failed to send command: write tcp [\d.:->\s]+/g,
|
||||
'failed to send command: connection error'
|
||||
);
|
||||
|
||||
sanitized = sanitized.replace(
|
||||
/write tcp [\d.:]+->[\d.:]+: i\/o timeout/g,
|
||||
'connection timed out'
|
||||
);
|
||||
sanitized = sanitized.replace(
|
||||
/read tcp [\d.:]+: i\/o timeout/g,
|
||||
'connection timed out'
|
||||
);
|
||||
sanitized = sanitized.replace(
|
||||
/dial tcp [\d.:]+: connection refused/g,
|
||||
'connection refused'
|
||||
);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// Extract guest name from context if available
|
||||
export const getGuestName = (context?: Record<string, unknown>): string | undefined => {
|
||||
if (!context) return undefined;
|
||||
if (typeof context.guestName === 'string') return context.guestName;
|
||||
if (typeof context.name === 'string') return context.name;
|
||||
return undefined;
|
||||
};
|
||||
98
frontend-modern/src/stores/__tests__/aiChat.test.ts
Normal file
98
frontend-modern/src/stores/__tests__/aiChat.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
|
||||
describe('aiChatStore', () => {
|
||||
beforeEach(() => {
|
||||
aiChatStore.close();
|
||||
aiChatStore.clearContext();
|
||||
aiChatStore.clearAllContext();
|
||||
aiChatStore.clearConversation();
|
||||
aiChatStore.setMessages([]);
|
||||
aiChatStore.registerInput(null);
|
||||
aiChatStore.setEnabled(false);
|
||||
});
|
||||
|
||||
it('opens, closes, and toggles', () => {
|
||||
expect(aiChatStore.isOpen).toBe(false);
|
||||
aiChatStore.open();
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
aiChatStore.toggle();
|
||||
expect(aiChatStore.isOpen).toBe(false);
|
||||
aiChatStore.toggle();
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
aiChatStore.close();
|
||||
expect(aiChatStore.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('sets legacy context and clears it', () => {
|
||||
aiChatStore.setContext({ targetType: 'vm', targetId: 'vm-101', context: { name: 'vm-101' } });
|
||||
expect(aiChatStore.context.targetType).toBe('vm');
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
aiChatStore.clearContext();
|
||||
expect(aiChatStore.context.targetType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds context items without duplicates and updates legacy context', () => {
|
||||
expect(aiChatStore.contextItems).toHaveLength(0);
|
||||
expect(aiChatStore.hasContextItem('vm-101')).toBe(false);
|
||||
|
||||
aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { guestName: 'vm-101', a: 1 });
|
||||
expect(aiChatStore.contextItems).toHaveLength(1);
|
||||
expect(aiChatStore.hasContextItem('vm-101')).toBe(true);
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
expect(aiChatStore.context.context).toMatchObject({ a: 1 });
|
||||
|
||||
aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { guestName: 'vm-101', a: 2 });
|
||||
expect(aiChatStore.contextItems).toHaveLength(1);
|
||||
expect(aiChatStore.contextItems[0].data).toMatchObject({ a: 2 });
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
expect(aiChatStore.context.context).toMatchObject({ a: 2 });
|
||||
});
|
||||
|
||||
it('removes context items and keeps legacy context consistent', () => {
|
||||
aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { name: 'vm-101' });
|
||||
aiChatStore.addContextItem('node', 'node-1', 'node-1', { name: 'node-1' });
|
||||
expect(aiChatStore.contextItems).toHaveLength(2);
|
||||
expect(aiChatStore.context.targetId).toBe('node-1');
|
||||
|
||||
aiChatStore.removeContextItem('node-1');
|
||||
expect(aiChatStore.contextItems).toHaveLength(1);
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
|
||||
aiChatStore.removeContextItem('vm-101');
|
||||
expect(aiChatStore.contextItems).toHaveLength(0);
|
||||
expect(aiChatStore.context.targetId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setTargetContext and openForTarget derive a sensible name', () => {
|
||||
aiChatStore.setTargetContext('vm', 'vm-101', { guestName: 'my-guest' });
|
||||
expect(aiChatStore.contextItems).toHaveLength(1);
|
||||
expect(aiChatStore.contextItems[0].name).toBe('my-guest');
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
|
||||
aiChatStore.openForTarget('node', 'node-1', { name: 'delly' });
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
expect(aiChatStore.contextItems).toHaveLength(2);
|
||||
expect(aiChatStore.contextItems[1].name).toBe('delly');
|
||||
});
|
||||
|
||||
it('opens with a pre-filled prompt', () => {
|
||||
aiChatStore.openWithPrompt('hello', { targetType: 'vm', targetId: 'vm-101' });
|
||||
expect(aiChatStore.isOpen).toBe(true);
|
||||
expect(aiChatStore.context.initialPrompt).toBe('hello');
|
||||
expect(aiChatStore.context.targetId).toBe('vm-101');
|
||||
});
|
||||
|
||||
it('focusInput returns false when closed and true when open with a registered element', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
aiChatStore.registerInput(textarea);
|
||||
|
||||
expect(aiChatStore.focusInput()).toBe(false);
|
||||
aiChatStore.open();
|
||||
expect(aiChatStore.focusInput()).toBe(true);
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
|
||||
textarea.remove();
|
||||
});
|
||||
});
|
||||
48
internal/agentexec/policy_test.go
Normal file
48
internal/agentexec/policy_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package agentexec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompilePatternsIgnoresInvalidRegex(t *testing.T) {
|
||||
res := compilePatterns([]string{"^df(\\s|$)", "["})
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("expected 1 compiled regex, got %d", len(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPolicyEvaluate(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
command string
|
||||
want PolicyDecision
|
||||
}{
|
||||
{"blocked", "rm -rf /", PolicyBlock},
|
||||
{"blocked sudo", "sudo rm -rf /", PolicyBlock},
|
||||
{"auto approve", "df -h", PolicyAllow},
|
||||
{"require approval", "systemctl restart nginx", PolicyRequireApproval},
|
||||
{"unknown defaults to approval", "echo hello", PolicyRequireApproval},
|
||||
{"sudo with flags remains conservative", "sudo -u root df -h", PolicyRequireApproval},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := p.Evaluate(tc.command); got != tc.want {
|
||||
t.Fatalf("Evaluate(%q) = %q, want %q", tc.command, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyHelpers(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
if !p.IsBlocked("rm -rf /") {
|
||||
t.Fatalf("expected rm -rf / to be blocked")
|
||||
}
|
||||
if !p.NeedsApproval("echo hello") {
|
||||
t.Fatalf("expected echo hello to require approval by default")
|
||||
}
|
||||
if !p.IsAutoApproved("df -h") {
|
||||
t.Fatalf("expected df -h to be auto approved")
|
||||
}
|
||||
}
|
||||
50
internal/agentexec/server_test.go
Normal file
50
internal/agentexec/server_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package agentexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExecuteCommandAgentNotConnected(t *testing.T) {
|
||||
s := NewServer(nil)
|
||||
_, err := s.ExecuteCommand(context.Background(), "missing", ExecuteCommandPayload{RequestID: "r1", Timeout: 1})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when agent not connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileAgentNotConnected(t *testing.T) {
|
||||
s := NewServer(nil)
|
||||
_, err := s.ReadFile(context.Background(), "missing", ReadFilePayload{RequestID: "r1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when agent not connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectedAgentLookups(t *testing.T) {
|
||||
s := NewServer(nil)
|
||||
now := time.Now().Add(-1 * time.Minute)
|
||||
|
||||
s.mu.Lock()
|
||||
s.agents["a1"] = &agentConn{agent: ConnectedAgent{AgentID: "a1", Hostname: "host1", ConnectedAt: now}}
|
||||
s.agents["a2"] = &agentConn{agent: ConnectedAgent{AgentID: "a2", Hostname: "host2", ConnectedAt: now}}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !s.IsAgentConnected("a1") {
|
||||
t.Fatalf("expected a1 to be connected")
|
||||
}
|
||||
if s.IsAgentConnected("missing") {
|
||||
t.Fatalf("expected missing to not be connected")
|
||||
}
|
||||
|
||||
agentID, ok := s.GetAgentForHost("host2")
|
||||
if !ok || agentID != "a2" {
|
||||
t.Fatalf("expected GetAgentForHost(host2) = (a2, true), got (%q, %v)", agentID, ok)
|
||||
}
|
||||
|
||||
agents := s.GetConnectedAgents()
|
||||
if len(agents) != 2 {
|
||||
t.Fatalf("expected 2 connected agents, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
114
internal/ceph/collector_test.go
Normal file
114
internal/ceph/collector_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package ceph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"fsid":"fsid-123",
|
||||
"health":{
|
||||
"status":"HEALTH_WARN",
|
||||
"checks":{
|
||||
"OSD_DOWN":{
|
||||
"severity":"HEALTH_WARN",
|
||||
"summary":{"message":"1 osd down"},
|
||||
"detail":[{"message":"osd.1 is down"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"monmap":{"epoch":7,"mons":[{"name":"a","rank":0,"addr":"10.0.0.1"}]},
|
||||
"mgrmap":{"available":true,"active_name":"mgr-a","standbys":[{"name":"mgr-b"}]},
|
||||
"osdmap":{"epoch":3,"num_osds":3,"num_up_osds":2,"num_in_osds":1},
|
||||
"pgmap":{
|
||||
"num_pgs":64,
|
||||
"bytes_total":1000,
|
||||
"bytes_used":250,
|
||||
"bytes_avail":750,
|
||||
"data_bytes":200,
|
||||
"degraded_ratio":0.1,
|
||||
"misplaced_ratio":0.2,
|
||||
"read_bytes_sec":1,
|
||||
"write_bytes_sec":2,
|
||||
"read_op_per_sec":3,
|
||||
"write_op_per_sec":4
|
||||
}
|
||||
}`)
|
||||
|
||||
status, err := parseStatus(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseStatus returned error: %v", err)
|
||||
}
|
||||
|
||||
if status.FSID != "fsid-123" {
|
||||
t.Fatalf("expected FSID fsid-123, got %q", status.FSID)
|
||||
}
|
||||
if status.Health.Status != "HEALTH_WARN" {
|
||||
t.Fatalf("expected HEALTH_WARN, got %q", status.Health.Status)
|
||||
}
|
||||
check, ok := status.Health.Checks["OSD_DOWN"]
|
||||
if !ok || check.Severity != "HEALTH_WARN" || check.Message != "1 osd down" || len(check.Detail) != 1 {
|
||||
t.Fatalf("unexpected parsed health checks: %+v", status.Health.Checks)
|
||||
}
|
||||
|
||||
if status.MonMap.NumMons != 1 || len(status.MonMap.Monitors) != 1 {
|
||||
t.Fatalf("expected 1 monitor, got %+v", status.MonMap)
|
||||
}
|
||||
if status.OSDMap.NumOSDs != 3 || status.OSDMap.NumUp != 2 || status.OSDMap.NumIn != 1 {
|
||||
t.Fatalf("unexpected OSD map: %+v", status.OSDMap)
|
||||
}
|
||||
if status.OSDMap.NumDown != 1 || status.OSDMap.NumOut != 2 {
|
||||
t.Fatalf("expected computed down/out counts, got %+v", status.OSDMap)
|
||||
}
|
||||
|
||||
if status.PGMap.UsagePercent != 25.0 {
|
||||
t.Fatalf("expected usage percent 25.0, got %v", status.PGMap.UsagePercent)
|
||||
}
|
||||
|
||||
if len(status.Services) != 3 {
|
||||
t.Fatalf("expected 3 service summaries, got %d", len(status.Services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusInvalidJSON(t *testing.T) {
|
||||
_, err := parseStatus([]byte(`{not-json}`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDF(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"stats":{"total_bytes":1000,"total_used_bytes":123,"percent_used":0.1234},
|
||||
"pools":[
|
||||
{"id":1,"name":"pool-a","stats":{"bytes_used":10,"max_avail":90,"objects":7,"percent_used":0.2}}
|
||||
]
|
||||
}`)
|
||||
|
||||
pools, usagePercent, err := parseDF(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDF returned error: %v", err)
|
||||
}
|
||||
if usagePercent != 12.34 {
|
||||
t.Fatalf("expected percent_used 12.34, got %v", usagePercent)
|
||||
}
|
||||
if len(pools) != 1 || pools[0].Name != "pool-a" || pools[0].PercentUsed != 20.0 {
|
||||
t.Fatalf("unexpected pools parsed: %+v", pools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDFInvalidJSON(t *testing.T) {
|
||||
_, _, err := parseDF([]byte(`{not-json}`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolToInt(t *testing.T) {
|
||||
if boolToInt(true) != 1 {
|
||||
t.Fatalf("expected boolToInt(true)=1")
|
||||
}
|
||||
if boolToInt(false) != 0 {
|
||||
t.Fatalf("expected boolToInt(false)=0")
|
||||
}
|
||||
}
|
||||
101
internal/metrics/store_test.go
Normal file
101
internal/metrics/store_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreWriteBatchAndQuery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig(dir)
|
||||
cfg.DBPath = filepath.Join(dir, "metrics-test.db")
|
||||
cfg.FlushInterval = time.Hour
|
||||
cfg.RetentionRaw = 10 * time.Second
|
||||
cfg.RetentionMinute = 20 * time.Second
|
||||
cfg.RetentionHourly = 30 * time.Second
|
||||
cfg.RetentionDaily = 40 * time.Second
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ts := time.Unix(1000, 0)
|
||||
store.writeBatch([]bufferedMetric{
|
||||
{resourceType: "vm", resourceID: "vm-101", metricType: "cpu", value: 1.5, timestamp: ts},
|
||||
{resourceType: "vm", resourceID: "vm-101", metricType: "cpu", value: 2.5, timestamp: ts.Add(1 * time.Second)},
|
||||
})
|
||||
|
||||
points, err := store.Query("vm", "vm-101", "cpu", ts.Add(-1*time.Second), ts.Add(2*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("Query returned error: %v", err)
|
||||
}
|
||||
if len(points) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(points))
|
||||
}
|
||||
if points[0].Value != 1.5 || points[1].Value != 2.5 {
|
||||
t.Fatalf("unexpected query values: %+v", points)
|
||||
}
|
||||
|
||||
all, err := store.QueryAll("vm", "vm-101", ts.Add(-1*time.Second), ts.Add(2*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("QueryAll returned error: %v", err)
|
||||
}
|
||||
if len(all["cpu"]) != 2 {
|
||||
t.Fatalf("expected QueryAll to return 2 cpu points, got %+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSelectTierAndStats(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig(dir)
|
||||
cfg.DBPath = filepath.Join(dir, "metrics-test.db")
|
||||
cfg.FlushInterval = time.Hour
|
||||
cfg.RetentionRaw = 10 * time.Second
|
||||
cfg.RetentionMinute = 20 * time.Second
|
||||
cfg.RetentionHourly = 30 * time.Second
|
||||
cfg.RetentionDaily = 40 * time.Second
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if store.selectTier(5*time.Second) != TierRaw {
|
||||
t.Fatalf("expected raw tier")
|
||||
}
|
||||
if store.selectTier(15*time.Second) != TierMinute {
|
||||
t.Fatalf("expected minute tier")
|
||||
}
|
||||
if store.selectTier(25*time.Second) != TierHourly {
|
||||
t.Fatalf("expected hourly tier")
|
||||
}
|
||||
if store.selectTier(35*time.Second) != TierDaily {
|
||||
t.Fatalf("expected daily tier")
|
||||
}
|
||||
|
||||
// Insert one point for each tier to verify stats aggregation.
|
||||
ts := int64(1000)
|
||||
_, err = store.db.Exec(
|
||||
`INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier) VALUES
|
||||
('vm','vm-101','cpu',1.0,?, 'raw'),
|
||||
('vm','vm-101','cpu',2.0,?, 'minute'),
|
||||
('vm','vm-101','cpu',3.0,?, 'hourly'),
|
||||
('vm','vm-101','cpu',4.0,?, 'daily')`,
|
||||
ts, ts, ts, ts,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert metrics returned error: %v", err)
|
||||
}
|
||||
|
||||
stats := store.GetStats()
|
||||
if stats.RawCount != 1 || stats.MinuteCount != 1 || stats.HourlyCount != 1 || stats.DailyCount != 1 {
|
||||
t.Fatalf("unexpected tier counts: %+v", stats)
|
||||
}
|
||||
if stats.DBPath == "" || stats.DBSize <= 0 {
|
||||
t.Fatalf("expected stats DB info to be populated: %+v", stats)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue