Initial testing infrastructure and tests

This commit is contained in:
Mike Thicke 2025-08-07 14:14:09 -04:00
parent a1c0efe7c2
commit 62f81c20ba
12 changed files with 2831 additions and 2 deletions

View file

@ -5,7 +5,11 @@
"main": "index.ts",
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
"build": "vite build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"author": "Epistemic Technology",
"license": "MIT",
@ -20,13 +24,19 @@
"zod": "^3.25.36"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.24",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"esbuild": "^0.25.5",
"jsdom": "^26.1.0",
"obsidian": "^1.8.7",
"postcss": "^8.5.4",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.11.6",
"vite-plugin-static-copy": "^2.3.1"
"vite-plugin-static-copy": "^2.3.1",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { debounce } from '../debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should delay function execution', async () => {
const mockFn = vi.fn().mockResolvedValue(undefined);
const debouncedFn = debounce(mockFn, 100);
debouncedFn('test');
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledWith('test');
});
it('should cancel previous execution when called multiple times', async () => {
const mockFn = vi.fn().mockResolvedValue(undefined);
const debouncedFn = debounce(mockFn, 100);
debouncedFn('first');
vi.advanceTimersByTime(50);
debouncedFn('second');
vi.advanceTimersByTime(50);
debouncedFn('third');
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('third');
});
it('should handle multiple arguments', async () => {
const mockFn = vi.fn().mockResolvedValue(undefined);
const debouncedFn = debounce(mockFn, 100);
debouncedFn('arg1', 'arg2', 'arg3');
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
});
it('should handle async function execution', async () => {
const mockFn = vi.fn().mockImplementation(async (value: string) => {
await new Promise(resolve => setTimeout(resolve, 10));
return value.toUpperCase();
});
const debouncedFn = debounce(mockFn, 100);
debouncedFn('test');
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledWith('test');
});
});

View file

@ -0,0 +1,268 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
isCoiNote,
isPathCoiNote,
isActiveCoiNote,
isPathActiveCoiNote,
sanitizeForTagName,
serializeCoiNoteContent,
deserializeCoiNoteContent
} from '../notes';
import { TFile, App } from 'obsidian';
describe('notes utilities', () => {
let mockApp: App;
let mockFile: TFile;
beforeEach(() => {
mockApp = new App();
mockFile = new TFile('test.md');
});
describe('isCoiNote', () => {
it('should return true for COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': true }
});
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(true);
});
it('should return false for non-COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': false }
});
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
it('should return false when frontmatter is missing', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue(null);
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
});
describe('isPathCoiNote', () => {
it('should return true for COI note paths', () => {
mockApp.metadataCache.getCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': true }
});
const result = isPathCoiNote('test.md', mockApp);
expect(result).toBe(true);
});
it('should return false for empty path', () => {
const result = isPathCoiNote('', mockApp);
expect(result).toBe(false);
});
});
describe('isActiveCoiNote', () => {
it('should return true for active COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': true
}
});
const result = isActiveCoiNote(mockFile, mockApp);
expect(result).toBe(true);
});
it('should return false for inactive COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': false
}
});
const result = isActiveCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
});
describe('isPathActiveCoiNote', () => {
it('should return true for active COI note paths', () => {
mockApp.metadataCache.getCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': true
}
});
const result = isPathActiveCoiNote('test.md', mockApp);
expect(result).toBe(true);
});
it('should return false for empty path', () => {
const result = isPathActiveCoiNote('', mockApp);
expect(result).toBe(false);
});
});
describe('sanitizeForTagName', () => {
it('should sanitize special characters', () => {
const result = sanitizeForTagName('Hello World!@#$%');
expect(result).toBe('hello-world');
});
it('should preserve alphanumeric and allowed characters', () => {
const result = sanitizeForTagName('test_tag-123');
expect(result).toBe('test_tag-123');
});
it('should remove leading and trailing dashes', () => {
const result = sanitizeForTagName('--test-tag--');
expect(result).toBe('test-tag');
});
it('should collapse multiple dashes', () => {
const result = sanitizeForTagName('test---multiple--dashes');
expect(result).toBe('test-multiple-dashes');
});
it('should handle empty string', () => {
const result = sanitizeForTagName('');
expect(result).toBe('');
});
it('should trim whitespace', () => {
const result = sanitizeForTagName(' test tag ');
expect(result).toBe('test-tag');
});
});
describe('serializeCoiNoteContent', () => {
it('should serialize basic chat messages', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there!' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result).toContain('## user:\n\nHello');
expect(result).toContain('## assistant:\n\nHi there!');
});
it('should include sources when provided', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: 'Question' }
];
const sources = [
{ title: 'Test Source', url: 'https://example.com' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null, sources);
expect(result).toContain('## Sources');
expect(result).toContain('1. [Test Source](https://example.com)');
});
it('should preserve content before and after chat section', async () => {
const currentContent = 'Before\n<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->\nAfter';
const messages = [
{ role: 'user' as const, content: 'Test' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result.startsWith('Before')).toBe(true);
expect(result.endsWith('After')).toBe(true);
});
it('should adjust header levels in content', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: '# Main Header\n## Sub Header' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result).toContain('### Main Header');
expect(result).toContain('#### Sub Header');
});
});
describe('deserializeCoiNoteContent', () => {
it('should deserialize basic chat messages', async () => {
const content = `
<!-- CHAT-THREAD-START -->
## user:
Hello
## assistant:
Hi there!
<!-- CHAT-THREAD-END -->
`.trim();
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]).toEqual({ role: 'user', content: 'Hello' });
expect(result.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' });
});
it('should deserialize sources', async () => {
const content = `
<!-- CHAT-THREAD-START -->
## user:
Question
## Sources
1. [Test Source](https://example.com)
2. [Another Source](https://test.com)
<!-- CHAT-THREAD-END -->
`.trim();
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.sources).toHaveLength(2);
expect(result.sources[0]).toEqual({ title: 'Test Source', url: 'https://example.com' });
expect(result.sources[1]).toEqual({ title: 'Another Source', url: 'https://test.com' });
});
it('should return empty result when no chat section found', async () => {
const content = 'Regular note content without chat section';
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.messages).toHaveLength(0);
expect(result.sources).toHaveLength(0);
expect(result.contextItems).toEqual({ notes: [], tags: [], sources: [] });
});
it('should handle context items from frontmatter', async () => {
const content = '<!-- CHAT-THREAD-START -->\n<!-- CHAT-THREAD-END -->';
const metadata = {
frontmatter: {
'linked-notes': ['note1.md', 'note2.md'],
'linked-tags': ['#tag1', '#tag2']
}
};
// Mock files for linked notes
mockApp.vault.getAbstractFileByPath = vi.fn()
.mockReturnValueOnce(new TFile('note1.md'))
.mockReturnValueOnce(new TFile('note2.md'));
const result = await deserializeCoiNoteContent(content, metadata, mockApp);
expect(result.contextItems.notes).toHaveLength(2);
expect(result.contextItems.tags).toEqual(['#tag1', '#tag2']);
});
});
});

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { getDomainFromUrl, ensureSourceTitle } from '../url';
describe('url utilities', () => {
describe('getDomainFromUrl', () => {
it('should extract domain from valid HTTPS URL', () => {
const result = getDomainFromUrl('https://www.example.com/path/to/page');
expect(result).toBe('www.example.com');
});
it('should extract domain from valid HTTP URL', () => {
const result = getDomainFromUrl('http://example.org/some/path');
expect(result).toBe('example.org');
});
it('should extract domain without protocol', () => {
const result = getDomainFromUrl('example.com/path');
expect(result).toBe('example.com');
});
it('should handle URLs with www prefix', () => {
const result = getDomainFromUrl('www.test-site.co.uk');
expect(result).toBe('test-site.co.uk');
});
it('should handle URLs with ports', () => {
const result = getDomainFromUrl('https://localhost:3000/api/endpoint');
expect(result).toBe('localhost');
});
it('should fallback to manual parsing for invalid URLs', () => {
const result = getDomainFromUrl('not-a-valid-url');
expect(result).toBe('not-a-valid-url');
});
it('should handle empty string', () => {
const result = getDomainFromUrl('');
expect(result).toBe('');
});
it('should handle complex URLs with subdomains', () => {
const result = getDomainFromUrl('https://api.subdomain.example.com/v1/data');
expect(result).toBe('api.subdomain.example.com');
});
});
describe('ensureSourceTitle', () => {
it('should keep existing title when provided', () => {
const source = { url: 'https://example.com', title: 'My Title' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'My Title'
});
});
it('should trim whitespace from existing title', () => {
const source = { url: 'https://example.com', title: ' My Title ' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'My Title'
});
});
it('should use domain as fallback when title is empty', () => {
const source = { url: 'https://www.example.com/page', title: '' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://www.example.com/page',
title: 'www.example.com'
});
});
it('should use domain as fallback when title is only whitespace', () => {
const source = { url: 'https://example.org/path', title: ' ' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.org/path',
title: 'example.org'
});
});
it('should use domain as fallback when title is missing', () => {
const source = { url: 'https://test.com/api/data' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://test.com/api/data',
title: 'test.com'
});
});
it('should preserve other properties from source', () => {
const source = {
url: 'https://example.com',
title: 'Test',
customProp: 'value'
} as any;
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'Test',
customProp: 'value'
});
});
});
});

209
test/mocks/obsidian.ts Normal file
View file

@ -0,0 +1,209 @@
import { vi } from 'vitest';
// Minimal Obsidian API mocks - only implement what we actually use
export class TFile {
path: string;
basename: string;
extension: string;
name: string;
parent: TFolder | null = null;
constructor(path: string) {
this.path = path;
this.name = path.split('/').pop() || '';
this.basename = this.name.replace(/\.[^/.]+$/, '');
this.extension = path.split('.').pop() || '';
// Set parent if path has directories
const parentPath = path.split('/').slice(0, -1).join('/');
if (parentPath) {
this.parent = new TFolder(parentPath);
}
}
}
export class TFolder {
path: string;
name: string;
constructor(path: string) {
this.path = path;
this.name = path.split('/').pop() || '';
}
}
export class Plugin {
app: App;
manifest: any;
constructor(app: App, manifest: any) {
this.app = app;
this.manifest = manifest;
}
addCommand = vi.fn().mockReturnValue(this);
addSettingTab = vi.fn().mockReturnValue(this);
registerView = vi.fn().mockReturnValue(this);
saveData = vi.fn().mockResolvedValue(undefined);
loadData = vi.fn().mockResolvedValue({});
onload = vi.fn();
onunload = vi.fn();
}
export class App {
vault = {
create: vi.fn().mockResolvedValue(new TFile('test.md')),
modify: vi.fn().mockResolvedValue(undefined),
read: vi.fn().mockResolvedValue(''),
cachedRead: vi.fn().mockResolvedValue(''),
getMarkdownFiles: vi.fn().mockReturnValue([]),
delete: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockReturnValue(true),
getAbstractFileByPath: vi.fn(),
createFolder: vi.fn().mockResolvedValue(new TFolder('test')),
};
workspace = {
getLeaf: vi.fn().mockReturnValue({
view: null,
setViewState: vi.fn().mockResolvedValue(undefined),
openFile: vi.fn().mockResolvedValue(undefined),
}),
activeLeaf: null,
on: vi.fn(),
off: vi.fn(),
trigger: vi.fn(),
getLeavesOfType: vi.fn().mockReturnValue([]),
detachLeavesOfType: vi.fn(),
};
metadataCache = {
getFileCache: vi.fn().mockReturnValue(null),
getCache: vi.fn().mockReturnValue(null),
on: vi.fn(),
off: vi.fn(),
};
fileManager = {
processFrontMatter: vi.fn(),
generateMarkdownLink: vi.fn(),
renameFile: vi.fn().mockResolvedValue(undefined),
};
}
export class WorkspaceLeaf {
view: any = null;
setViewState = vi.fn().mockResolvedValue(undefined);
detach = vi.fn();
getViewState = vi.fn().mockReturnValue({});
}
export class ItemView {
app: App;
leaf: WorkspaceLeaf;
constructor(leaf: WorkspaceLeaf) {
this.leaf = leaf;
this.app = leaf.view?.app || new App();
}
onOpen = vi.fn().mockResolvedValue(undefined);
onClose = vi.fn().mockResolvedValue(undefined);
getDisplayText = vi.fn().mockReturnValue('Test View');
getViewType = vi.fn().mockReturnValue('test-view');
}
export class TextFileView extends ItemView {
file: TFile | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
onLoadFile = vi.fn().mockResolvedValue(undefined);
onUnloadFile = vi.fn().mockResolvedValue(undefined);
getViewData = vi.fn().mockReturnValue('');
setViewData = vi.fn();
clear = vi.fn();
}
export class PluginSettingTab {
app: App;
plugin: Plugin;
constructor(app: App, plugin: Plugin) {
this.app = app;
this.plugin = plugin;
}
display = vi.fn();
hide = vi.fn();
}
export class Setting {
constructor(containerEl: HTMLElement) {}
setName = vi.fn().mockReturnThis();
setDesc = vi.fn().mockReturnThis();
addText = vi.fn().mockReturnThis();
addToggle = vi.fn().mockReturnThis();
addDropdown = vi.fn().mockReturnThis();
}
export class SuggestModal<T> {
app: App;
constructor(app: App) {
this.app = app;
}
open = vi.fn();
close = vi.fn();
getSuggestions = vi.fn().mockReturnValue([]);
renderSuggestion = vi.fn();
onChooseSuggestion = vi.fn();
}
export class Modal {
app: App;
constructor(app: App) {
this.app = app;
}
open = vi.fn();
close = vi.fn();
onOpen = vi.fn();
onClose = vi.fn();
}
// Mock global functions
export const addIcon = vi.fn();
export const debounce = vi.fn((fn: Function, delay: number) => fn);
export const normalizePath = vi.fn((path: string) => path);
export const parseFrontMatterEntry = vi.fn();
export const parseFrontMatterStringArray = vi.fn();
export const parseFrontMatterTags = vi.fn();
// Default export for compatibility
export default {
TFile,
TFolder,
Plugin,
App,
WorkspaceLeaf,
ItemView,
TextFileView,
PluginSettingTab,
Setting,
SuggestModal,
Modal,
addIcon,
debounce,
normalizePath,
parseFrontMatterEntry,
parseFrontMatterStringArray,
parseFrontMatterTags,
};

31
test/setup.ts Normal file
View file

@ -0,0 +1,31 @@
import { vi } from 'vitest';
// Global test setup and configuration
global.console = {
...console,
// Suppress console.log in tests unless needed for debugging
log: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: console.error, // Keep error logging for debugging
};
// Add Obsidian extensions to built-in types
declare global {
interface String {
contains(searchString: string): boolean;
}
}
// Polyfill for Obsidian's String.contains method
String.prototype.contains = function(searchString: string): boolean {
return this.includes(searchString);
};
// Clean up after each test
afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});

273
test/testing.md Normal file
View file

@ -0,0 +1,273 @@
# Testing Guide for Co-Intelligence AI
This document provides instructions for running tests and understanding the testing infrastructure for the Co-Intelligence AI Obsidian plugin.
## Quick Start
```bash
# Run all tests
npm test
# Run tests in watch mode (interactive)
npm test
# Run tests once and exit
npm run test:run
# Run tests with coverage report
npm run test:coverage
# Open interactive test UI in browser
npm run test:ui
```
## Testing Commands
### Basic Testing
- `npm test` - Run tests in watch mode (recommended for development)
- `npm run test:run` - Run all tests once and exit (used in CI)
### Coverage & Analysis
- `npm run test:coverage` - Generate detailed coverage report (HTML + terminal)
- `npm run test:ui` - Open Vitest UI for interactive test exploration
### Development Tips
- Use `npm test` during development - it watches for file changes and re-runs tests automatically
- Use `npm run test:ui` for debugging complex test scenarios with the visual interface
- Check `coverage/index.html` after running coverage command for detailed HTML report
## Directory Structure
```
test/
├── testing.md # This guide
├── setup.ts # Global test setup and configuration
├── mocks/ # Mock implementations
│ └── obsidian.ts # Obsidian API mocks
├── fixtures/ # Test data (planned)
│ ├── chat-data.ts # Sample chat messages
│ ├── ai-responses.ts # Mock AI responses
│ └── notes.ts # Sample Obsidian notes
└── utils/ # Test utilities
├── test-helpers.ts # Common helper functions
└── render.tsx # Custom Solid.js render utilities
src/
├── **/__tests__/ # Unit tests (co-located with source)
│ ├── *.test.ts # TypeScript/utility tests
│ └── *.test.tsx # Component tests
└── ...
```
## Testing Approach
### 1. Unit Tests (Current Focus)
**Location**: `src/**/__tests__/*.test.ts`
**Purpose**: Test individual functions and utilities in isolation
Examples:
- `src/utils/__tests__/debounce.test.ts` - Debounce function logic
- `src/utils/__tests__/url.test.ts` - URL parsing and validation
- `src/utils/__tests__/notes.test.ts` - Note serialization/deserialization
### 2. Service Tests (Phase 2)
**Location**: `src/services/__tests__/*.test.ts`
**Purpose**: Test core business logic with mocked dependencies
Planned tests:
- Model registry behavior
- AI streaming with abort handling
- Context preparation for prompts
### 3. Component Tests (Phase 3)
**Location**: `src/components/__tests__/*.test.tsx`
**Purpose**: Test Solid.js components with user interactions
Planned tests:
- User input handling
- Message rendering
- Model selector behavior
## Mock Strategy
### Obsidian API Mocks
We use minimal mocks in `test/mocks/obsidian.ts` that only implement methods actually used by our code:
```typescript
// Example: Mock only what you need
export class App {
vault = {
create: vi.fn().mockResolvedValue(new TFile('test.md')),
read: vi.fn().mockResolvedValue(''),
// ... only mocked methods we use
};
}
```
### AI SDK Testing
The AI SDK provides built-in testing utilities:
```typescript
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
```
### Obsidian Extensions
We polyfill Obsidian-specific extensions in `test/setup.ts`:
```typescript
// Adds String.contains() method used by Obsidian
String.prototype.contains = function(searchString: string): boolean {
return this.includes(searchString);
};
```
## Writing Tests
### Basic Test Structure
```typescript
import { describe, it, expect, vi } from 'vitest';
import { functionToTest } from '../source-file';
describe('functionToTest', () => {
it('should do something specific', () => {
const result = functionToTest('input');
expect(result).toBe('expected');
});
});
```
### Testing Async Functions
```typescript
it('should handle async operations', async () => {
const result = await asyncFunction();
expect(result).toBeDefined();
});
```
### Mocking Dependencies
```typescript
import { vi } from 'vitest';
const mockFn = vi.fn().mockReturnValue('mocked result');
const mockAsync = vi.fn().mockResolvedValue('async result');
```
### Testing Solid.js Components (Phase 3)
```typescript
import { render, screen } from '@solidjs/testing-library';
import { MyComponent } from '../MyComponent';
it('should render correctly', () => {
render(() => <MyComponent prop="value" />);
expect(screen.getByText('Expected Text')).toBeInTheDocument();
});
```
## Configuration Files
### vitest.config.ts
Main test configuration with:
- Solid.js plugin integration
- Path aliases matching main Vite config
- Obsidian mock aliasing
- Coverage settings
- jsdom environment for DOM testing
### test/setup.ts
Global test setup that runs before all tests:
- Console suppression for cleaner test output
- Obsidian API extensions (String.contains, etc.)
- Mock cleanup between tests
## Coverage Goals
### Current Coverage (Phase 1)
- ✅ **debounce.ts**: 100% coverage
- ✅ **url.ts**: 100% coverage
- ✅ **notes.ts**: 46% coverage (core functions tested)
### Target Coverage (Future Phases)
- **Services**: 90%+ coverage
- **Components**: 70%+ coverage
- **Overall**: 60%+ coverage
Coverage reports help identify untested code paths and ensure quality.
## Best Practices
### DO:
- ✅ Test behavior, not implementation details
- ✅ Use descriptive test names: `should handle empty input gracefully`
- ✅ Mock external dependencies (Obsidian API, network calls)
- ✅ Test edge cases and error conditions
- ✅ Keep tests fast and independent
### DON'T:
- ❌ Test framework internals (Solid.js reactivity, Obsidian's behavior)
- ❌ Create elaborate mocks that mirror entire APIs
- ❌ Write slow tests with real file I/O or network requests
- ❌ Make tests depend on each other's state
## Troubleshooting
### Common Issues
#### Tests Not Finding Obsidian Imports
**Solution**: Check that `vitest.config.ts` has the obsidian alias:
```typescript
resolve: {
alias: {
'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts')
}
}
```
#### Solid.js Components Not Rendering
**Solution**: Ensure jsdom environment is configured:
```typescript
test: {
environment: 'jsdom'
}
```
#### Missing Mock Methods
**Solution**: Add methods to `test/mocks/obsidian.ts` as you discover them:
```typescript
// Add missing methods as needed
someObsidianClass = {
newMethod: vi.fn().mockReturnValue('default'),
// ...
}
```
#### String.contains() Not Found
This is handled in `test/setup.ts` but if you see this error, ensure setup files are properly configured in `vitest.config.ts`.
## Future Phases
### Phase 2: Core Services Testing
- Model registry singleton behavior
- AI streaming with real response simulation
- Context injection and preparation
- Error handling and abort scenarios
### Phase 3: Component Testing
- User interaction simulation
- State management testing
- Integration between components
- Accessibility testing
### Phase 4: Integration & E2E
- Complete workflow testing
- Plugin lifecycle testing
- Settings persistence testing
- CI/CD pipeline integration
## Resources
- [Vitest Documentation](https://vitest.dev/)
- [Solid.js Testing Library](https://github.com/solidjs/solid-testing-library)
- [AI SDK Testing Guide](https://sdk.vercel.ai/docs)
- [Testing Implementation Plan](../testing-implementation.md) - Full implementation strategy
---
For questions or issues with testing, refer to the main implementation plan or create an issue in the repository.

7
test/utils/render.tsx Normal file
View file

@ -0,0 +1,7 @@
import { render as solidRender } from '@solidjs/testing-library';
import { JSX } from 'solid-js';
// Custom render function with default providers if needed
export const render = (component: () => JSX.Element) => {
return solidRender(component);
};

View file

@ -0,0 +1,14 @@
import { vi } from 'vitest';
// Common test utilities
export const mockFn = <T extends (...args: any[]) => any>() => vi.fn<Parameters<T>, ReturnType<T>>();
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const createMockFile = (path: string) => ({
path,
basename: path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '',
extension: path.split('.').pop() || '',
name: path.split('/').pop() || '',
});

615
testing-implementation.md Normal file
View file

@ -0,0 +1,615 @@
# Testing Implementation Plan for Co-Intelligence AI
## Executive Summary
This document outlines a comprehensive testing strategy for the Co-Intelligence AI Obsidian plugin. The plugin presents unique testing challenges due to its architecture: a Solid.js UI framework, integration with Obsidian's API, AI SDK streaming capabilities, and the constraint that Obsidian plugins must compile to a single `main.js` file.
Our approach uses Vitest as the primary testing framework (leveraging existing Vite configuration), the AI SDK's built-in testing utilities for mocking AI responses, and minimal Obsidian API mocks to enable unit and integration testing without requiring a full Obsidian environment.
## Technology Stack
### Core Testing Framework
- **Vitest** - Primary test runner (chosen over Jest for seamless Vite integration)
- **@vitest/ui** - Interactive UI for exploring test results
- **@vitest/coverage-v8** - Code coverage reporting
- **jsdom** - DOM environment for testing Solid.js components
### Solid.js Testing
- **@solidjs/testing-library** - Solid-specific testing utilities
- **@testing-library/user-event** - Realistic user interaction simulation
### AI SDK Testing
- **Built-in test utilities from 'ai/test'**:
- `MockLanguageModelV2` - Mock language model
- `simulateReadableStream` - Stream simulation with realistic delays
- No additional mocking libraries needed for AI responses
### Existing Dependencies (Already in project)
- **typescript** - Type checking
- **vite** - Build tool
- **solid-js** - UI framework
## Project Setup
### Step 1: Install Testing Dependencies
```bash
npm install -D vitest @vitest/ui @vitest/coverage-v8 jsdom @solidjs/testing-library @testing-library/user-event
```
### Step 2: Configure Vitest
Create `vitest.config.ts` in the project root:
```typescript
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import solid from 'vite-plugin-solid';
import path from 'path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [solid()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@assets': path.resolve(__dirname, './assets'),
'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts')
}
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**'
]
}
}
};
});
```
### Step 3: Update package.json Scripts
```json
{
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
```
### Step 4: Create Test Infrastructure
```
test/
├── setup.ts # Global test setup and configuration
├── mocks/
│ ├── obsidian.ts # Minimal Obsidian API mocks
│ └── solid-context.tsx # Mock providers for Solid.js components
├── fixtures/
│ ├── chat-data.ts # Sample chat messages and threads
│ ├── ai-responses.ts # Predefined AI response patterns
│ └── notes.ts # Sample Obsidian notes
└── utils/
├── test-helpers.ts # Common test utilities
└── render.tsx # Custom render with providers
```
## Testing Architecture
### Test Categories
#### 1. Unit Tests (Priority: High)
- **Location**: `src/**/__tests__/*.test.ts`
- **Targets**: Pure functions, utilities, business logic
- **Examples**:
- URL validation in `utils/url.ts`
- Debounce logic in `utils/debounce.ts`
- Note serialization in `utils/notes.ts`
#### 2. Service Tests (Priority: High)
- **Location**: `src/services/__tests__/*.test.ts`
- **Targets**: Core services with mocked dependencies
- **Examples**:
- Model registry singleton behavior
- AI streaming responses with abort handling
- Context preparation for prompts
#### 3. Component Tests (Priority: Medium)
- **Location**: `src/components/__tests__/*.test.tsx`
- **Targets**: Solid.js components with mocked contexts
- **Examples**:
- User input handling and keyboard shortcuts
- Message rendering and interactions
- Model selector behavior
#### 4. Integration Tests (Priority: Low)
- **Location**: `test/integration/*.test.ts`
- **Targets**: Multi-component workflows
- **Examples**:
- Complete chat creation flow
- Settings persistence
- View switching
### Mocking Strategy
#### Obsidian API Mocks
Create minimal mocks that only implement the methods actually used:
```typescript
// test/mocks/obsidian.ts
export class TFile {
path: string;
basename: string;
extension: string;
constructor(path: string) {
this.path = path;
this.basename = path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '';
this.extension = path.split('.').pop() || '';
}
}
export class Plugin {
app: App;
manifest: any;
constructor(app: App, manifest: any) {
this.app = app;
this.manifest = manifest;
}
addCommand() { return this; }
addSettingTab() { return this; }
registerView() { return this; }
saveData() { return Promise.resolve(); }
loadData() { return Promise.resolve({}); }
}
export class App {
vault = {
create: jest.fn(),
modify: jest.fn(),
read: jest.fn(),
getMarkdownFiles: jest.fn(() => []),
};
workspace = {
getLeaf: jest.fn(),
activeLeaf: null,
};
metadataCache = {
getFileCache: jest.fn(),
};
}
```
#### AI SDK Mock Patterns
```typescript
// test/fixtures/ai-responses.ts
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
export const createMockModel = (response: string) => {
return new MockLanguageModelV2({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: response },
{ type: 'finish', finishReason: 'stop' }
]
})
})
});
};
export const createMockStreamingModel = (chunks: string[]) => {
return new MockLanguageModelV2({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
...chunks.map(chunk => ({ type: 'text-delta', textDelta: chunk })),
{ type: 'finish', finishReason: 'stop' }
]
})
})
});
};
```
## Implementation Phases
### Phase 1: Foundation (Week 1)
1. **Day 1-2**: Set up Vitest configuration and test infrastructure
2. **Day 3-4**: Create minimal Obsidian mocks
3. **Day 5**: Write first utility function tests (debounce, url validation)
4. **Day 6-7**: Establish testing patterns and documentation
**Deliverables**:
- Working test environment
- 5-10 utility function tests
- Testing guidelines document
### Phase 2: Core Services (Week 2)
1. **Day 1-2**: Test `model-registry.ts`
- Singleton pattern
- Provider initialization
- Model availability checks
2. **Day 3-4**: Test `model-service.ts`
- Stream handling with AI SDK mocks
- Abort controller behavior
- Context injection
3. **Day 5-7**: Test note utilities
- COI note creation
- Serialization/deserialization
- Markdown parsing
**Deliverables**:
- 90%+ coverage for services
- Documentation of service testing patterns
### Phase 3: Component Testing (Week 3)
1. **Day 1-2**: Set up Solid.js testing utilities
2. **Day 3-4**: Test simple components (buttons, icons)
3. **Day 5-7**: Test complex components
- ChatInterface
- UserInput
- ModelSelector
**Deliverables**:
- Component test suite
- Custom render utilities
- Interaction testing examples
### Phase 4: Integration & Polish (Week 4)
1. **Day 1-2**: Plugin lifecycle tests
2. **Day 3-4**: Chat workflow integration tests
3. **Day 5-6**: CI/CD setup
4. **Day 7**: Documentation and cleanup
**Deliverables**:
- Integration test suite
- GitHub Actions workflow
- Final documentation
## Code Examples
### Example 1: Testing the Model Service
```typescript
// src/services/__tests__/model-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
import { generateChatResponse } from '@/services/model-service';
import { ModelRegistry } from '@/services/model-registry';
describe('model-service', () => {
let mockRegistry: ModelRegistry;
beforeEach(() => {
mockRegistry = {
getLanguageModel: vi.fn(),
getModel: vi.fn(() => ({ toggleWebSearch: true }))
} as any;
});
it('should stream a chat response', async () => {
const mockModel = new MockLanguageModelV2({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: 'Hello' },
{ type: 'text-delta', textDelta: ' world' },
{ type: 'finish', finishReason: 'stop', usage: {
promptTokens: 10,
completionTokens: 2,
totalTokens: 12
}}
]
})
})
});
mockRegistry.getLanguageModel.mockReturnValue(mockModel);
const request = {
modelId: 'test-model',
messages: [{ role: 'user', content: 'Hi' }],
requestID: 'test-123',
webSearch: false
};
const result = await generateChatResponse(request, mockRegistry);
// Collect the streamed text
let fullText = '';
for await (const part of result.textStream) {
fullText += part;
}
expect(fullText).toBe('Hello world');
expect(mockRegistry.getLanguageModel).toHaveBeenCalledWith('test-model');
});
it('should handle abort signals', async () => {
// Test abort controller functionality
});
it('should inject context into system prompt', async () => {
// Test context preparation
});
});
```
### Example 2: Testing a Solid.js Component
```typescript
// src/components/__tests__/UserInput.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { UserInput } from '@/components/UserInput';
describe('UserInput', () => {
const defaultProps = {
onSendMessage: vi.fn(),
isLoading: false,
onStopGenerating: vi.fn()
};
it('should send message on Enter key', async () => {
const user = userEvent.setup();
render(() => <UserInput {...defaultProps} />);
const input = screen.getByPlaceholderText('Type your message...');
await user.type(input, 'Test message{Enter}');
expect(defaultProps.onSendMessage).toHaveBeenCalledWith('Test message');
expect(input).toHaveValue('');
});
it('should not send empty messages', async () => {
const user = userEvent.setup();
render(() => <UserInput {...defaultProps} />);
const input = screen.getByPlaceholderText('Type your message...');
await user.type(input, '{Enter}');
expect(defaultProps.onSendMessage).not.toHaveBeenCalled();
});
it('should show stop button when loading', () => {
render(() => <UserInput {...defaultProps} isLoading={true} />);
const stopButton = screen.getByLabelText('Stop generating');
expect(stopButton).toBeInTheDocument();
});
});
```
### Example 3: Testing Note Utilities
```typescript
// src/utils/__tests__/notes.test.ts
import { describe, it, expect } from 'vitest';
import { createCOINote, deserializeChatThread } from '@/utils/notes';
import { TFile } from 'obsidian';
describe('notes utilities', () => {
describe('createCOINote', () => {
it('should create a valid COI note structure', () => {
const note = createCOINote('Test Chat', [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' }
]);
expect(note).toContain('---');
expect(note).toContain('is-coi-chat: true');
expect(note).toContain('# Test Chat');
expect(note).toContain('<!-- CHAT-THREAD-START -->');
expect(note).toContain('<!-- CHAT-THREAD-END -->');
});
});
describe('deserializeChatThread', () => {
it('should parse messages from note content', () => {
const content = `
---
is-coi-chat: true
---
# Chat
<!-- CHAT-THREAD-START -->
[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi!"}]
<!-- CHAT-THREAD-END -->
`;
const messages = deserializeChatThread(content);
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual({ role: 'user', content: 'Hello' });
expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi!' });
});
});
});
```
## CI/CD Integration
### GitHub Actions Workflow
Create `.github/workflows/test.yml`:
```yaml
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
```
## Best Practices
### General Testing Guidelines
1. **Test Behavior, Not Implementation**
- Focus on public APIs and user-facing behavior
- Avoid testing internal implementation details
2. **Keep Tests Fast**
- Mock external dependencies (Obsidian API, AI providers)
- Use in-memory operations instead of file I/O
3. **Write Descriptive Test Names**
```typescript
// Good
it('should display error message when model API key is missing')
// Bad
it('test error')
```
4. **Follow AAA Pattern**
- **Arrange**: Set up test data and dependencies
- **Act**: Execute the code under test
- **Assert**: Verify the results
### Obsidian Plugin-Specific Considerations
1. **Mock Minimally**
- Only mock the Obsidian APIs your code actually uses
- Keep mocks simple and focused
2. **Handle Async Operations**
- Most Obsidian APIs are async
- Use `async/await` in tests consistently
3. **Test File Operations Carefully**
```typescript
// Mock file operations instead of actual I/O
vault.create = vi.fn().mockResolvedValue(new TFile('test.md'));
```
4. **Settings Management**
- Use in-memory settings for tests
- Reset settings between tests
### AI SDK Testing Patterns
1. **Test Different Response Scenarios**
- Successful completion
- Partial responses
- Error handling
- Abort/cancellation
2. **Simulate Realistic Streaming**
```typescript
simulateReadableStream({
chunks: [...],
chunkDelayInMs: 50 // Simulate typing delay
})
```
3. **Test Context Handling**
- Empty context
- Large context
- Special characters in context
## Common Pitfalls and Solutions
### Pitfall 1: Over-Mocking
**Problem**: Creating elaborate mocks that mirror entire Obsidian API
**Solution**: Mock only what you use, add mocks as needed
### Pitfall 2: Testing Framework Code
**Problem**: Testing Solid.js reactivity or Obsidian's internal behavior
**Solution**: Focus on your business logic and user interactions
### Pitfall 3: Slow Tests
**Problem**: Tests that perform real file I/O or network requests
**Solution**: Use mocks and in-memory operations
### Pitfall 4: Brittle Tests
**Problem**: Tests that break with minor implementation changes
**Solution**: Test public APIs and user-facing behavior
## Troubleshooting
### Vitest + Solid.js Issues
1. **Component not rendering**
- Ensure `@solidjs/testing-library` is properly configured
- Check that jsdom environment is set
2. **Reactivity not working in tests**
- Wrap state changes in `act()` if needed
- Use `waitFor` for async updates
### Obsidian Mock Issues
1. **Missing API methods**
- Add methods to mocks as you discover them
- Keep a shared mock file updated
2. **Type errors**
- Use TypeScript's `Partial` types for mocks
- Cast to `any` when necessary (document why)
### AI SDK Test Issues
1. **Stream not completing**
- Ensure you include a finish chunk
- Check for proper error handling
2. **Timing issues**
- Use `waitFor` for async assertions
- Adjust chunk delays if needed
## Conclusion
This testing implementation plan provides a pragmatic approach to testing the Co-Intelligence AI Obsidian plugin. By leveraging Vitest's integration with Vite, the AI SDK's built-in testing utilities, and minimal Obsidian mocks, we can achieve good test coverage without the complexity of full end-to-end testing.
Remember: Start small, focus on high-value tests, and progressively improve coverage as the codebase evolves. The goal is not 100% coverage but confidence that critical functionality works correctly.

35
vitest.config.ts Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import solid from 'vite-plugin-solid';
import path from 'path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [solid()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@assets': path.resolve(__dirname, './assets'),
'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts')
}
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**'
]
}
}
};
});