Compare commits

..

4 Commits

Author SHA1 Message Date
hikari 8cb4c17dc1 test: expand test coverage for backend and frontend modules
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 58s
CI / Lint & Test (pull_request) Failing after 5m51s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
- Add 25+ tests for temp_manager.rs (0% -> 96.59% coverage)
- Expand sessions.rs tests (23% -> 68.50% coverage)
- Expand quick_actions.rs tests (23% -> 71.13% coverage)
- Expand snippets.rs tests (23% -> 72.32% coverage)
- Expand stats.rs tests with cost calculation and streak tests
- Add frontend test infrastructure with Tauri mocks
- Add tests for conversations, quickActions, and snippets stores
- Total backend tests: 298 passing
2026-01-25 23:18:03 -08:00
naomi bee4eb393e ci: enforce coverage thresholds in CI pipeline
- Run frontend tests with coverage thresholds (15% lines/statements)
- Add llvm-tools-preview component for Rust coverage
- Install cargo-llvm-cov and enforce 50% line coverage for backend
- CI will now fail if coverage drops below thresholds
2026-01-25 22:49:59 -08:00
naomi 78b951a865 ci: add coverage tooling for frontend and backend
- Add @vitest/coverage-v8 for frontend test coverage
- Configure vitest with coverage thresholds (15% statements/lines, 13% branches/functions)
- Add npm scripts for backend testing and coverage with cargo-llvm-cov
- Add test:all and coverage:all scripts for unified test execution
- Exclude coverage directory from git tracking
2026-01-25 22:48:01 -08:00
hikari cd15247dea test: add comprehensive test coverage for backend and frontend
Backend (177 new tests):
- achievements.rs: 108 tests covering all achievement categories, unlock logic,
  serialization, and progress tracking
- commands.rs: 19 tests for validate_directory, get_file_size, and struct serialization
- git.rs: 31 tests with real temporary git repos for status, diff, stage, commit, log
- clipboard.rs: 19 tests for ClipboardEntry, ClipboardHistory, sorting, and filtering

Frontend (141 new tests):
- stats.test.ts: 19 tests for stats store and formattedStats derived store
- config.test.ts: 40 tests for theme, font size, path masking, and config store
- slashCommands.test.ts: 48 tests for parseSlashCommand, getMatchingCommands, isSlashCommand
- notifications.test.ts: 34 tests for NotificationType, NOTIFICATION_SOUNDS, SoundPlayer

Total test count increased from ~55 to 373 tests (216 backend + 157 frontend)
2026-01-25 22:38:48 -08:00
19 changed files with 128 additions and 42 deletions
+74
View File
@@ -0,0 +1,74 @@
# Hikari Desktop v0.3.0 Release Notes
## New Features
### AskUserQuestion Tool Support (#51)
- Claude can now ask you questions with multiple choice options during conversations
- A dedicated modal appears with answer choices and support for custom responses
- Answers are seamlessly integrated back into the conversation context
### Slash Commands
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
- `/search <query>` - Search and highlight matches within the conversation (#32)
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
### Auto-Update Checker (#17)
- Automatically checks for new releases on startup
- Notification appears when a newer version is available
- Can be disabled in settings
### Font Size & Zoom (#19)
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
- Font size slider in settings (10-24px range)
- Preference persists between sessions
### Resizable Character Panel (#10)
- Drag the divider between the character panel and terminal to resize
- Panel width is saved and restored on app restart
- Sprite now uses full height for better proportions
### Input History Navigation (#13)
- Use up/down arrows to navigate through previous messages and commands
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
### Keyboard Shortcuts (#21)
- `Ctrl+N` - New conversation
- `Ctrl+W` - Close current tab
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
- `Ctrl+L` - Clear conversation
- `Ctrl+,` - Open settings
### Always On Top Toggle (#28)
- Pin the window to stay above other applications
- Toggle in settings
## Improvements
### UI/UX
- Resizable chat input with drag handle (expands upward)
- Send button properly aligned with input field
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
- Light mode text colors improved for better readability
- Scroll position persists per conversation tab when switching
- Confirmation modal when closing connected tabs
- Links in chat now open in default browser (#54)
- Spaces allowed when renaming tabs (#52)
### State Management
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
- Multiple tabs can now request permissions simultaneously without conflicts
## Closed Issues
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
+1 -1
View File
@@ -27,6 +27,6 @@ export default tseslint.config(
},
},
{
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"],
}
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.0.0",
"version": "0.3.0",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.0.0"
version = "0.3.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+2 -5
View File
@@ -408,15 +408,13 @@ mod tests {
#[test]
fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100);
// Compile-time assertions for constant bounds
const _: () = assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
assert!(MAX_HISTORY_SIZE > 0);
assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
}
// ==================== Pinned entry sorting tests ====================
#[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
@@ -466,7 +464,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
+4 -4
View File
@@ -611,7 +611,7 @@ mod tests {
#[test]
fn test_update_info_serialization() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
current_version: "0.3.0".to_string(),
latest_version: "0.4.0".to_string(),
has_update: true,
release_url: "https://example.com/release".to_string(),
@@ -619,7 +619,7 @@ mod tests {
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("1.0.0"));
assert!(json.contains("0.3.0"));
assert!(json.contains("0.4.0"));
assert!(json.contains("true"));
assert!(json.contains("New features!"));
@@ -628,8 +628,8 @@ mod tests {
#[test]
fn test_update_info_without_notes() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
latest_version: "1.0.0".to_string(),
current_version: "0.3.0".to_string(),
latest_version: "0.3.0".to_string(),
has_update: false,
release_url: "https://example.com/release".to_string(),
release_notes: None,
+2 -2
View File
@@ -733,8 +733,8 @@ mod tests {
let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty
if let Ok(commits) = result {
assert!(commits.is_empty());
if result.is_ok() {
assert!(result.unwrap().is_empty());
}
}
-2
View File
@@ -247,7 +247,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![
create_test_action("custom-z", "Zebra", false),
@@ -355,7 +354,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() {
let actions = vec![
create_test_action("action-1", "First", false),
-1
View File
@@ -321,7 +321,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() {
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
-3
View File
@@ -281,7 +281,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false),
@@ -389,7 +388,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() {
let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false),
@@ -406,7 +404,6 @@ mod tests {
}
#[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false),
+3 -2
View File
@@ -523,8 +523,9 @@ mod tests {
let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now());
// Verify duration is returned (u64 is always non-negative)
let _duration = stats.get_session_duration();
// The duration should be at least 0 seconds
let duration = stats.get_session_duration();
assert!(duration >= 0);
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.0.0",
"version": "0.3.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+10 -6
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
import {
NotificationType,
NOTIFICATION_SOUNDS,
type NotificationSound,
} from "./types";
// Mock HTMLAudioElement for soundPlayer tests
class MockAudioElement {
@@ -23,7 +27,7 @@ class MockAudioElement {
}
// Store original Audio before mocking
const OriginalAudio = globalThis.Audio;
const OriginalAudio = global.Audio;
describe("notifications", () => {
describe("NotificationType enum", () => {
@@ -162,12 +166,12 @@ describe("notifications", () => {
describe("SoundPlayer class", () => {
beforeEach(() => {
// Mock Audio constructor
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
global.Audio = MockAudioElement as unknown as typeof Audio;
});
afterEach(() => {
// Restore original Audio
globalThis.Audio = OriginalAudio;
global.Audio = OriginalAudio;
vi.resetModules();
});
@@ -232,12 +236,12 @@ describe("notifications", () => {
describe("NotificationManager class", () => {
beforeEach(() => {
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
global.Audio = MockAudioElement as unknown as typeof Audio;
vi.resetModules();
});
afterEach(() => {
globalThis.Audio = OriginalAudio;
global.Audio = OriginalAudio;
});
it("can import notificationManager singleton", async () => {
+5
View File
@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import {
configStore,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
maskPaths,
clampFontSize,
applyFontSize,
+15 -8
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
// Test the Conversation interface and store behavior
describe("Conversation interface", () => {
@@ -251,7 +252,7 @@ describe("conversation management operations", () => {
});
it("uses default name when not provided", () => {
const counter = 5;
let counter = 5;
const createNewConversation = (name?: string) => ({
name: name || `Conversation ${counter}`,
});
@@ -270,7 +271,9 @@ describe("conversation history formatting", () => {
{ type: "user" as const, content: "How are you?" },
];
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const history = relevantLines
.map((line) => {
@@ -287,7 +290,9 @@ describe("conversation history formatting", () => {
it("returns empty string for no messages", () => {
const lines: Array<{ type: string; content: string }> = [];
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
expect(relevantLines.length).toBe(0);
const history = relevantLines.length === 0 ? "" : "has content";
@@ -328,7 +333,7 @@ describe("scroll position handling", () => {
});
it("uses positive values for manual scroll position", () => {
const scrollPosition: number = 500;
const scrollPosition = 500;
const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(false);
@@ -447,7 +452,9 @@ describe("attachment management", () => {
});
it("clears all attachments", () => {
const cleared: Array<{ id: string }> = [];
const attachments = [{ id: "att-1" }, { id: "att-2" }];
const cleared: typeof attachments = [];
expect(cleared).toHaveLength(0);
});
});
@@ -461,7 +468,7 @@ describe("derived store behavior", () => {
});
it("defaults to disconnected when no active conversation", () => {
const activeConv = null as { connectionStatus?: string } | null;
const activeConv = null;
const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("disconnected");
@@ -480,7 +487,7 @@ describe("derived store behavior", () => {
});
it("defaults to empty array when no active conversation", () => {
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
const activeConv = null;
const derivedLines = activeConv?.terminalLines || [];
expect(derivedLines).toEqual([]);
+2 -1
View File
@@ -264,7 +264,8 @@ describe("quickActionsStore", () => {
describe("quick action ID generation", () => {
it("generates unique custom action IDs", () => {
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const generateId = () =>
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
+3 -2
View File
@@ -289,7 +289,8 @@ describe("snippetsStore", () => {
describe("snippet ID generation", () => {
it("generates unique custom snippet IDs", () => {
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const generateId = () =>
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
@@ -334,7 +335,7 @@ git push`,
it("supports content with special characters", () => {
const snippet = {
content: "echo \"Hello, World!\" && echo 'Single quotes'",
content: 'echo "Hello, World!" && echo \'Single quotes\'',
};
expect(snippet.content).toContain('"');
+2
View File
@@ -319,6 +319,8 @@ describe("tauri event handling", () => {
describe("mock event system", () => {
it("can emit events through mock system", () => {
const handler = vi.fn();
// The emitMockEvent function should work
expect(typeof emitMockEvent).toBe("function");
});
+2 -2
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { vi, beforeEach } from "vitest";
import { vi } from "vitest";
// Mock Tauri invoke API
const mockInvokeResults: Record<string, unknown> = {};
@@ -13,7 +13,7 @@ export function clearMockInvokeResults() {
}
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn((command: string) => {
invoke: vi.fn((command: string, _args?: Record<string, unknown>) => {
if (command in mockInvokeResults) {
const result = mockInvokeResults[command];
if (result instanceof Error) {