mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
Initial testing infrastructure and tests
This commit is contained in:
parent
a1c0efe7c2
commit
62f81c20ba
12 changed files with 2831 additions and 2 deletions
14
package.json
14
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1184
pnpm-lock.yaml
1184
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
70
src/utils/__tests__/debounce.test.ts
Normal file
70
src/utils/__tests__/debounce.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
268
src/utils/__tests__/notes.test.ts
Normal file
268
src/utils/__tests__/notes.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/utils/__tests__/url.test.ts
Normal file
113
src/utils/__tests__/url.test.ts
Normal 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
209
test/mocks/obsidian.ts
Normal 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
31
test/setup.ts
Normal 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
273
test/testing.md
Normal 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
7
test/utils/render.tsx
Normal 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);
|
||||
};
|
||||
14
test/utils/test-helpers.ts
Normal file
14
test/utils/test-helpers.ts
Normal 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
615
testing-implementation.md
Normal 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
35
vitest.config.ts
Normal 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/**'
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue