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", "name": "hikari-desktop",
"version": "1.0.0", "version": "0.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.0.0" version = "0.3.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+2 -5
View File
@@ -408,15 +408,13 @@ mod tests {
#[test] #[test]
fn test_max_history_size_is_reasonable() { fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100); assert_eq!(MAX_HISTORY_SIZE, 100);
// Compile-time assertions for constant bounds assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE > 0); assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
} }
// ==================== Pinned entry sorting tests ==================== // ==================== Pinned entry sorting tests ====================
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() { fn test_pinned_entries_sorting() {
let mut entries = vec![ let mut entries = vec![
ClipboardEntry { ClipboardEntry {
@@ -466,7 +464,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() { fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![ let mut entries = vec![
ClipboardEntry { ClipboardEntry {
+4 -4
View File
@@ -611,7 +611,7 @@ mod tests {
#[test] #[test]
fn test_update_info_serialization() { fn test_update_info_serialization() {
let info = UpdateInfo { let info = UpdateInfo {
current_version: "1.0.0".to_string(), current_version: "0.3.0".to_string(),
latest_version: "0.4.0".to_string(), latest_version: "0.4.0".to_string(),
has_update: true, has_update: true,
release_url: "https://example.com/release".to_string(), release_url: "https://example.com/release".to_string(),
@@ -619,7 +619,7 @@ mod tests {
}; };
let json = serde_json::to_string(&info).unwrap(); 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("0.4.0"));
assert!(json.contains("true")); assert!(json.contains("true"));
assert!(json.contains("New features!")); assert!(json.contains("New features!"));
@@ -628,8 +628,8 @@ mod tests {
#[test] #[test]
fn test_update_info_without_notes() { fn test_update_info_without_notes() {
let info = UpdateInfo { let info = UpdateInfo {
current_version: "1.0.0".to_string(), current_version: "0.3.0".to_string(),
latest_version: "1.0.0".to_string(), latest_version: "0.3.0".to_string(),
has_update: false, has_update: false,
release_url: "https://example.com/release".to_string(), release_url: "https://example.com/release".to_string(),
release_notes: None, release_notes: None,
+2 -2
View File
@@ -733,8 +733,8 @@ mod tests {
let result = git_log(working_dir, Some(10)); let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty // May fail on empty repo or return empty
if let Ok(commits) = result { if result.is_ok() {
assert!(commits.is_empty()); assert!(result.unwrap().is_empty());
} }
} }
-2
View File
@@ -247,7 +247,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() { fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![ let mut actions = vec![
create_test_action("custom-z", "Zebra", false), create_test_action("custom-z", "Zebra", false),
@@ -355,7 +354,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() { fn test_action_find_by_id() {
let actions = vec![ let actions = vec![
create_test_action("action-1", "First", false), create_test_action("action-1", "First", false),
-1
View File
@@ -321,7 +321,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() { fn test_session_sorting_by_activity() {
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 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(); 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] #[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() { fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![ let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false), create_test_snippet("s1", "Zebra", "B-Category", false),
@@ -389,7 +388,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() { fn test_snippet_find_by_id() {
let snippets = vec![ let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false), create_test_snippet("snippet-1", "First", "Cat", false),
@@ -406,7 +404,6 @@ mod tests {
} }
#[test] #[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() { fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![ let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false), create_test_snippet("s1", "S1", "Zebra", false),
+3 -2
View File
@@ -523,8 +523,9 @@ mod tests {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now()); stats.session_start = Some(Instant::now());
// Verify duration is returned (u64 is always non-negative) // The duration should be at least 0 seconds
let _duration = stats.get_session_duration(); let duration = stats.get_session_duration();
assert!(duration >= 0);
} }
#[test] #[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.0.0", "version": "0.3.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+10 -6
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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 // Mock HTMLAudioElement for soundPlayer tests
class MockAudioElement { class MockAudioElement {
@@ -23,7 +27,7 @@ class MockAudioElement {
} }
// Store original Audio before mocking // Store original Audio before mocking
const OriginalAudio = globalThis.Audio; const OriginalAudio = global.Audio;
describe("notifications", () => { describe("notifications", () => {
describe("NotificationType enum", () => { describe("NotificationType enum", () => {
@@ -162,12 +166,12 @@ describe("notifications", () => {
describe("SoundPlayer class", () => { describe("SoundPlayer class", () => {
beforeEach(() => { beforeEach(() => {
// Mock Audio constructor // Mock Audio constructor
globalThis.Audio = MockAudioElement as unknown as typeof Audio; global.Audio = MockAudioElement as unknown as typeof Audio;
}); });
afterEach(() => { afterEach(() => {
// Restore original Audio // Restore original Audio
globalThis.Audio = OriginalAudio; global.Audio = OriginalAudio;
vi.resetModules(); vi.resetModules();
}); });
@@ -232,12 +236,12 @@ describe("notifications", () => {
describe("NotificationManager class", () => { describe("NotificationManager class", () => {
beforeEach(() => { beforeEach(() => {
globalThis.Audio = MockAudioElement as unknown as typeof Audio; global.Audio = MockAudioElement as unknown as typeof Audio;
vi.resetModules(); vi.resetModules();
}); });
afterEach(() => { afterEach(() => {
globalThis.Audio = OriginalAudio; global.Audio = OriginalAudio;
}); });
it("can import notificationManager singleton", async () => { it("can import notificationManager singleton", async () => {
+5
View File
@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { import {
configStore, configStore,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
maskPaths, maskPaths,
clampFontSize, clampFontSize,
applyFontSize, 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 // Test the Conversation interface and store behavior
describe("Conversation interface", () => { describe("Conversation interface", () => {
@@ -251,7 +252,7 @@ describe("conversation management operations", () => {
}); });
it("uses default name when not provided", () => { it("uses default name when not provided", () => {
const counter = 5; let counter = 5;
const createNewConversation = (name?: string) => ({ const createNewConversation = (name?: string) => ({
name: name || `Conversation ${counter}`, name: name || `Conversation ${counter}`,
}); });
@@ -270,7 +271,9 @@ describe("conversation history formatting", () => {
{ type: "user" as const, content: "How are you?" }, { 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 const history = relevantLines
.map((line) => { .map((line) => {
@@ -287,7 +290,9 @@ describe("conversation history formatting", () => {
it("returns empty string for no messages", () => { it("returns empty string for no messages", () => {
const lines: Array<{ type: string; content: string }> = []; 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); expect(relevantLines.length).toBe(0);
const history = relevantLines.length === 0 ? "" : "has content"; const history = relevantLines.length === 0 ? "" : "has content";
@@ -328,7 +333,7 @@ describe("scroll position handling", () => {
}); });
it("uses positive values for manual scroll position", () => { it("uses positive values for manual scroll position", () => {
const scrollPosition: number = 500; const scrollPosition = 500;
const isAutoScroll = scrollPosition === -1; const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(false); expect(isAutoScroll).toBe(false);
@@ -447,7 +452,9 @@ describe("attachment management", () => {
}); });
it("clears all attachments", () => { 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); expect(cleared).toHaveLength(0);
}); });
}); });
@@ -461,7 +468,7 @@ describe("derived store behavior", () => {
}); });
it("defaults to disconnected when no active conversation", () => { it("defaults to disconnected when no active conversation", () => {
const activeConv = null as { connectionStatus?: string } | null; const activeConv = null;
const derivedStatus = activeConv?.connectionStatus || "disconnected"; const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("disconnected"); expect(derivedStatus).toBe("disconnected");
@@ -480,7 +487,7 @@ describe("derived store behavior", () => {
}); });
it("defaults to empty array when no active conversation", () => { 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 || []; const derivedLines = activeConv?.terminalLines || [];
expect(derivedLines).toEqual([]); expect(derivedLines).toEqual([]);
+2 -1
View File
@@ -264,7 +264,8 @@ describe("quickActionsStore", () => {
describe("quick action ID generation", () => { describe("quick action ID generation", () => {
it("generates unique custom action IDs", () => { 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 id1 = generateId();
const id2 = generateId(); const id2 = generateId();
+3 -2
View File
@@ -289,7 +289,8 @@ describe("snippetsStore", () => {
describe("snippet ID generation", () => { describe("snippet ID generation", () => {
it("generates unique custom snippet IDs", () => { 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 id1 = generateId();
const id2 = generateId(); const id2 = generateId();
@@ -334,7 +335,7 @@ git push`,
it("supports content with special characters", () => { it("supports content with special characters", () => {
const snippet = { const snippet = {
content: "echo \"Hello, World!\" && echo 'Single quotes'", content: 'echo "Hello, World!" && echo \'Single quotes\'',
}; };
expect(snippet.content).toContain('"'); expect(snippet.content).toContain('"');
+2
View File
@@ -319,6 +319,8 @@ describe("tauri event handling", () => {
describe("mock event system", () => { describe("mock event system", () => {
it("can emit events through mock system", () => { it("can emit events through mock system", () => {
const handler = vi.fn();
// The emitMockEvent function should work // The emitMockEvent function should work
expect(typeof emitMockEvent).toBe("function"); expect(typeof emitMockEvent).toBe("function");
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { vi, beforeEach } from "vitest"; import { vi } from "vitest";
// Mock Tauri invoke API // Mock Tauri invoke API
const mockInvokeResults: Record<string, unknown> = {}; const mockInvokeResults: Record<string, unknown> = {};
@@ -13,7 +13,7 @@ export function clearMockInvokeResults() {
} }
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn((command: string) => { invoke: vi.fn((command: string, _args?: Record<string, unknown>) => {
if (command in mockInvokeResults) { if (command in mockInvokeResults) {
const result = mockInvokeResults[command]; const result = mockInvokeResults[command];
if (result instanceof Error) { if (result instanceof Error) {