diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 00000000..0fec74d6 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,487 @@ +# Deployment Guide + +This guide covers deploying `do-knowledge-studio` to various environments. + +## Overview + +`do-knowledge-studio` is a client-side application that can be deployed to: +- Static hosting (Netlify, Vercel, GitHub Pages, etc.) +- Self-hosted servers (Nginx, Apache, Caddy) +- CDN (Cloudflare, AWS CloudFront) + +The application uses: +- SQLite WASM (in-browser, no backend needed) +- OPFS (Origin Private File System) for data persistence +- Static assets (no server-side processing) + +## Build Process + +### Production Build + +```bash +pnpm run build +``` + +This creates an optimized production build in the `dist/` directory. + +### Build Output + +``` +dist/ +├── index.html +├── assets/ +│ ├── index-[hash].js +│ ├── index-[hash].css +│ ├── sqlite3.wasm +│ ├── sqlite3-worker1-bundler-friendly.js +│ └── [other assets] +└── [other files] +``` + +## Deployment Options + +### Option 1: Static Hosting (Recommended) + +#### Netlify + +1. **Via Git**: + - Connect your repository + - Build command: `pnpm run build` + - Publish directory: `dist` + - Node version: 22 + +2. **Via Netlify CLI**: + ```bash + npm install -g netlify-cli + pnpm run build + netlify deploy --prod --dir=dist + ``` + +3. **Configuration** (`netlify.toml`): + ```toml + [build] + command = "pnpm run build" + publish = "dist" + + [[headers]] + for = "/*.wasm" + [headers.values] + Content-Type = "application/wasm" + ``` + +#### Vercel + +1. **Via Git**: + - Import your repository + - Framework preset: Vite + - Build command: `pnpm run build` + - Output directory: `dist` + +2. **Via Vercel CLI**: + ```bash + npm install -g vercel + pnpm run build + vercel --prod + ``` + +#### GitHub Pages + +1. **Add to `package.json`**: + ```json + { + "homepage": "https://[username].github.io/do-knowledge-studio" + } + ``` + +2. **Build and deploy**: + ```bash + pnpm run build + pnpm run deploy + ``` + +3. **GitHub Actions** (`.github/workflows/deploy.yml`): + ```yaml + name: Deploy to GitHub Pages + on: + push: + branches: [main] + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 22 + - run: pnpm install + - run: pnpm run build + - uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist + ``` + +### Option 2: Self-Hosted + +#### Nginx + +1. **Build the application**: + ```bash + pnpm run build + ``` + +2. **Copy to server**: + ```bash + scp -r dist/* user@server:/var/www/dks/ + ``` + +3. **Nginx configuration** (`/etc/nginx/sites-available/dks`): + ```nginx + server { + listen 80; + server_name your-domain.com; + root /var/www/dks; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' 'wasm-unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" always; + + # WASM MIME type + types { + application/wasm wasm; + } + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # No cache for index.html + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # Gzip compression + gzip on; + gzip_types text/css application/javascript application/wasm application/json; + gzip_min_length 1000; + } + ``` + +4. **Enable and reload**: + ```bash + sudo ln -s /etc/nginx/sites-available/dks /etc/nginx/sites-enabled/ + sudo nginx -t + sudo systemctl reload nginx + ``` + +#### Apache + +1. **Build the application**: + ```bash + pnpm run build + ``` + +2. **Copy to server**: + ```bash + scp -r dist/* user@server:/var/www/dks/ + ``` + +3. **Apache configuration** (`/etc/apache2/sites-available/dks.conf`): + ```apache + + ServerName your-domain.com + DocumentRoot /var/www/dks + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Security headers + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" + Header set Referrer-Policy "no-referrer-when-downgrade" + + # WASM MIME type + AddType application/wasm .wasm + + # SPA routing + RewriteEngine On + RewriteBase / + RewriteRule ^index\.html$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.html [L] + + # Compression + + AddOutputFilterByType DEFLATE text/css application/javascript application/wasm application/json + + + ``` + +4. **Enable and reload**: + ```bash + sudo a2ensite dks + sudo systemctl reload apache2 + ``` + +#### Caddy + +1. **Caddyfile**: + ``` + your-domain.com { + root * /var/www/dks + encode gzip + + # Security headers + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "no-referrer-when-downgrade" + Content-Security-Policy "default-src 'self' 'wasm-unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" + } + + # SPA routing + try_files {path} /index.html + + # Cache static assets + @assets path /assets/* + header @assets Cache-Control "public, immutable, max-age=31536000" + } + ``` + +2. **Reload Caddy**: + ```bash + sudo systemctl reload caddy + ``` + +### Option 3: Docker + +1. **Create `Dockerfile`**: + ```dockerfile + FROM node:22-alpine AS builder + WORKDIR /app + COPY package.json pnpm-lock.yaml ./ + RUN corepack enable && pnpm install --frozen-lockfile + COPY . . + RUN pnpm run build + + FROM nginx:alpine + COPY --from=builder /app/dist /usr/share/nginx/html + COPY nginx.conf /etc/nginx/conf.d/default.conf + EXPOSE 80 + CMD ["nginx", "-g", "daemon off;"] + ``` + +2. **Create `nginx.conf`**: + ```nginx + server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + types { + application/wasm wasm; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + ``` + +3. **Build and run**: + ```bash + docker build -t do-knowledge-studio . + docker run -p 8080:80 do-knowledge-studio + ``` + +## HTTPS Configuration + +### Let's Encrypt (Certbot) + +```bash +# Nginx +sudo certbot --nginx -d your-domain.com + +# Apache +sudo certbot --apache -d your-domain.com + +# Auto-renewal +sudo certbot renew --dry-run +``` + +### Cloudflare +1. Add your domain to Cloudflare +2. Update nameservers +3. Enable "Full" or "Full (Strict)" SSL mode +4. Configure page rules for caching + +## Environment-Specific Configuration + +### Development +- Use `pnpm run dev` for local development +- Vite dev server with hot reload +- Source maps enabled + +### Staging +- Deploy to staging environment +- Use staging API keys +- Enable error tracking (Sentry, etc.) +- Disable analytics + +### Production +- Use production API keys +- Enable analytics (optional, privacy-respecting) +- Enable error tracking +- Configure CDN caching +- Set up monitoring + +## Performance Optimization + +### Build Optimization +- Code splitting by route +- Lazy loading of heavy components +- Tree shaking for unused code +- Asset compression (gzip/brotli) +- Image optimization + +### Runtime Optimization +- Service worker for offline support +- Browser caching headers +- CDN for static assets +- OPFS for local data persistence +- IndexedDB fallback for older browsers + +### Monitoring +- Core Web Vitals tracking +- Error rate monitoring +- API latency tracking +- User interaction analytics + +## Security Considerations + +### Required Headers +``` +Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval'; ... +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +Referrer-Policy: no-referrer-when-downgrade +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +### CORS Configuration +The application doesn't need CORS for most operations. If using external APIs: +``` +Access-Control-Allow-Origin: https://your-domain.com +``` + +### Rate Limiting +Configure rate limiting at the reverse proxy level: +```nginx +limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; +location /api/ { + limit_req zone=api burst=20; +} +``` + +## Backup and Recovery + +### User Data +All user data is stored in the browser via OPFS. Users should: +1. Export their data regularly (Settings → Export) +2. Store backups in a safe location +3. Import on new devices as needed + +### Application Data +No server-side data is stored. The application is stateless. + +## Troubleshooting + +### WASM Not Loading +- **Issue**: `.wasm` files not served with correct MIME type +- **Solution**: Configure server to serve `.wasm` as `application/wasm` + +### OPFS Not Available +- **Issue**: Browser doesn't support OPFS +- **Solution**: Use a supported browser (Chrome 111+, Firefox 115+, Safari 16.4+) + +### CORS Errors +- **Issue**: API calls blocked by CORS +- **Solution**: Configure CORS headers on API server + +### Performance Issues +- **Issue**: Slow loading or response times +- **Solution**: Enable compression, use CDN, optimize images + +## Monitoring and Analytics + +### Privacy-Respecting Analytics +- Use Plausible or Simple Analytics (no cookies) +- Self-hosted options available +- Respect Do Not Track headers + +### Error Tracking +- Sentry (self-hosted or cloud) +- LogRocket (session replay) +- Custom error reporting endpoint + +### Health Checks +```bash +# Check application health +curl -I https://your-domain.com + +# Check WASM assets +curl -I https://your-domain.com/assets/sqlite3-*.wasm +``` + +## Scaling Considerations + +### Client-Side Scaling +- Application scales automatically with users +- No server-side load +- CDN handles static asset delivery + +### Database Scaling +- SQLite WASM handles individual user data +- For multi-user scenarios, use separate OPFS partitions +- Consider server-side sync for cross-device access + +## Cost Optimization + +### Static Hosting +- **Netlify**: Free tier supports small projects +- **Vercel**: Free tier with generous limits +- **GitHub Pages**: Free for public repositories +- **Cloudflare Pages**: Free with unlimited bandwidth + +### Self-Hosting +- **VPS**: $5-20/month for small deployments +- **Dedicated**: $50+/month for high traffic +- **CDN**: $0-50/month depending on usage + +## Next Steps + +1. Choose a deployment option +2. Configure HTTPS +3. Set up monitoring +4. Configure backups +5. Test deployment +6. Monitor performance diff --git a/docs/LLM_SETUP.md b/docs/LLM_SETUP.md new file mode 100644 index 00000000..bb5e803a --- /dev/null +++ b/docs/LLM_SETUP.md @@ -0,0 +1,285 @@ +# LLM Setup Guide + +`do-knowledge-studio` supports multiple LLM providers through a plugin-based architecture. This guide covers setup and configuration for supported providers. + +## Supported Providers + +### Built-in +- **OpenRouter**: Multi-model router (https://openrouter.ai) +- **Kilo**: AI coding assistant (https://kilo.ai) + +### Custom +- Any OpenAI-compatible API endpoint +- Local models via Ollama, LM Studio, etc. + +## Quick Start + +### 1. Get an API Key + +#### OpenRouter +1. Visit https://openrouter.ai/keys +2. Sign in or create an account +3. Generate a new API key +4. Copy the key (starts with `sk-or-v1-`) + +#### Kilo +1. Visit https://kilo.ai/settings +2. Sign in or create an account +3. Generate an API key +4. Copy the key + +### 2. Configure in Application + +1. Open the application +2. Click Settings (gear icon) +3. Navigate to "AI Configuration" +4. Select your provider +5. Paste your API key +6. Click "Save" + +The API key is encrypted and stored locally in your browser. + +## Configuration + +### Environment Variables + +You can set default configuration via environment variables: + +```bash +# .env.local +VITE_LLM_PROVIDER=openrouter +VITE_LLM_API_KEY=sk-or-v1-... +VITE_LLM_MODEL=anthropic/claude-3.5-sonnet +``` + +**Note**: API keys in `VITE_*` variables are exposed to the browser. Use the application settings for production keys. + +### Programmatic Configuration + +```typescript +import { configureLLM } from './lib/llm/config'; + +configureLLM({ + provider: 'openrouter', + apiKey: 'sk-or-v1-...', + model: 'anthropic/claude-3.5-sonnet', + temperature: 0.7, + maxTokens: 2000, +}); +``` + +## Provider-Specific Setup + +### OpenRouter + +OpenRouter provides access to multiple models through a single API. + +#### Supported Models +- **Anthropic Claude 3.5 Sonnet**: `anthropic/claude-3.5-sonnet` +- **OpenAI GPT-4 Turbo**: `openai/gpt-4-turbo` +- **Meta Llama 3.1 70B**: `meta-llama/llama-3.1-70b-instruct` +- **Google Gemini Pro 1.5**: `google/gemini-pro-1.5` + +#### Configuration +```json +{ + "provider": "openrouter", + "apiKey": "sk-or-v1-...", + "model": "anthropic/claude-3.5-sonnet", + "baseUrl": "https://openrouter.ai/api/v1" +} +``` + +#### Cost Optimization +- Use `anthropic/claude-3-haiku` for cost-effective operations +- Set `maxTokens` to limit response length +- Enable response caching where available + +### Kilo + +Kilo is optimized for AI coding assistance. + +#### Configuration +```json +{ + "provider": "kilo", + "apiKey": "your-kilo-key", + "model": "kilo-coder" +} +``` + +### Custom OpenAI-Compatible Endpoints + +You can use any OpenAI-compatible API, including local models. + +#### Ollama (Local) +```json +{ + "provider": "custom", + "apiKey": "not-needed", + "baseUrl": "http://localhost:11434/v1", + "model": "llama3.1" +} +``` + +#### LM Studio (Local) +```json +{ + "provider": "custom", + "apiKey": "not-needed", + "baseUrl": "http://localhost:1234/v1", + "model": "local-model" +} +``` + +## Features + +### Chat Assistant +Ask questions about your knowledge base. The assistant uses semantic search to find relevant entities and claims. + +### Entity Extraction +Automatically extract entities and claims from text using AI. + +### Claim Verification +Verify claims against your existing knowledge base. + +### Graph Suggestions +Get AI suggestions for entity relationships. + +## Rate Limiting + +The application includes built-in rate limiting to prevent API quota exhaustion: + +- **Default**: 10 requests per minute +- **Configurable**: Adjust in settings +- **Per-provider**: Different limits per provider + +### Rate Limit Headers +Check the application console for rate limit information: +``` +Rate limit: 10/10 requests remaining (resets in 45s) +``` + +## Error Handling + +### Common Errors + +#### 401 Unauthorized +- **Cause**: Invalid or missing API key +- **Solution**: Verify API key in settings + +#### 429 Too Many Requests +- **Cause**: Rate limit exceeded +- **Solution**: Wait for rate limit reset or increase limit + +#### 500 Internal Server Error +- **Cause**: Provider API issue +- **Solution**: Try again or switch provider + +#### Network Error +- **Cause**: No internet connection +- **Solution**: Check connection and retry + +### Fallback Strategy + +The application supports automatic fallback to alternative providers: +```json +{ + "primary": "openrouter", + "fallback": ["kilo", "custom"] +} +``` + +## Security + +### API Key Storage +- Keys are encrypted using AES-GCM +- Encryption key derived from browser fingerprint +- Keys never leave your device +- Keys are not logged or transmitted in plain text + +### Best Practices +1. **Use the application settings** for API keys (not environment variables) +2. **Rotate keys regularly** (every 90 days recommended) +3. **Use separate keys** for development and production +4. **Monitor usage** through your provider's dashboard +5. **Set spending limits** in your provider account + +### Security Auditing + +Run the security audit script to check for exposed keys: +```bash +./scripts/audit-vite-env.sh +``` + +## Privacy + +### Data Handling +- Your data never leaves your device +- Only prompts and context are sent to LLM providers +- No telemetry or usage tracking +- You control what data is shared + +### Provider Privacy Policies +Review each provider's privacy policy: +- [OpenRouter Privacy](https://openrouter.ai/privacy) +- [Kilo Privacy](https://kilo.ai/privacy) + +## Troubleshooting + +### API Key Not Working +1. Verify the key is correct (no extra spaces) +2. Check if the key has expired +3. Ensure the key has the required permissions +4. Try regenerating the key + +### Slow Responses +1. Check your internet connection +2. Try a smaller/faster model +3. Reduce `maxTokens` setting +4. Enable response streaming + +### Incorrect Results +1. Adjust the `temperature` setting (lower = more focused) +2. Provide more context in your queries +3. Use a more capable model +4. Check the prompt templates in settings + +## Advanced Configuration + +### Custom Prompts +You can customize the system prompts used for each feature: + +```json +{ + "prompts": { + "chat": "You are a helpful assistant with access to a knowledge base...", + "extraction": "Extract entities and claims from the following text...", + "verification": "Verify this claim against the knowledge base..." + } +} +``` + +### Response Streaming +Enable streaming for real-time responses: +```json +{ + "streaming": true, + "streamChunkSize": 50 +} +``` + +### Model Parameters +Fine-tune model behavior: +```json +{ + "temperature": 0.7, + "topP": 0.9, + "frequencyPenalty": 0.0, + "presencePenalty": 0.0 +} +``` + +## API Reference + +See [LLM API Reference](LLM_API.md) for detailed API documentation. diff --git a/src/db/__tests__/DbProvider.test.tsx b/src/db/__tests__/DbProvider.test.tsx new file mode 100644 index 00000000..c197c506 --- /dev/null +++ b/src/db/__tests__/DbProvider.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { DbProvider, DbContext } from '../DbProvider'; + +// Mock the initDb function +vi.mock('../../db/client', () => ({ + initDb: vi.fn(), +})); + +// Mock the repository +vi.mock('../../db/repository', () => ({ + repository: { + listEntities: vi.fn().mockResolvedValue([]), + }, +})); + +// Mock the logger +vi.mock('../../lib/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('DbProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render children when database is ready', async () => { + const { initDb } = await import('../../db/client'); + vi.mocked(initDb).mockResolvedValue(undefined); + + render( + +
Test Child
+
+ ); + + // Wait for the database to initialize + await vi.waitFor(() => { + expect(screen.getByTestId('child')).toBeTruthy(); + }); + }); + + it('should show error state on failure', async () => { + const { initDb } = await import('../../db/client'); + vi.mocked(initDb).mockRejectedValue(new Error('DB Error')); + + const ErrorConsumer = () => { + const context = React.useContext(DbContext); + if (context?.error) { + return
{context.error}
; + } + return null; + }; + + render( + + + + ); + + await vi.waitFor(() => { + expect(screen.getByTestId('error')).toBeTruthy(); + expect(screen.getByTestId('error').textContent).toBe('Failed to initialize local database'); + }); + }); + + it('should provide repository in context', async () => { + const { initDb } = await import('../../db/client'); + vi.mocked(initDb).mockResolvedValue(undefined); + + const ContextConsumer = () => { + const context = React.useContext(DbContext); + return ( +
{context?.repository ? 'yes' : 'no'}
+ ); + }; + + render( + + + + ); + + await vi.waitFor(() => { + expect(screen.getByTestId('has-repository').textContent).toBe('yes'); + }); + }); + + it('should set dbReady to true after initialization', async () => { + const { initDb } = await import('../../db/client'); + vi.mocked(initDb).mockResolvedValue(undefined); + + const StateConsumer = () => { + const context = React.useContext(DbContext); + return ( +
{context?.dbReady ? 'ready' : 'not ready'}
+ ); + }; + + render( + + + + ); + + await vi.waitFor(() => { + expect(screen.getByTestId('db-ready').textContent).toBe('ready'); + }); + }); +}); diff --git a/src/hooks/__tests__/useFocusTrap.test.ts b/src/hooks/__tests__/useFocusTrap.test.ts new file mode 100644 index 00000000..af0dee9b --- /dev/null +++ b/src/hooks/__tests__/useFocusTrap.test.ts @@ -0,0 +1,128 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useRef } from 'react'; +import { useFocusTrap } from '../useFocusTrap'; + +describe('useFocusTrap', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + container.innerHTML = ` + + + + `; + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + vi.restoreAllMocks(); + }); + + it('should focus first focusable element when activated', () => { + const firstButton = container.querySelector('#first') as HTMLElement; + firstButton.focus(); + + renderHook(() => { + const ref = useRef(container); + useFocusTrap(ref, true); + return ref; + }); + + expect(document.activeElement).toBe(firstButton); + }); + + it('should restore previous focus on deactivation', () => { + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + outsideButton.focus(); + + const { rerender } = renderHook( + ({ active }) => { + const ref = useRef(container); + useFocusTrap(ref, active); + return ref; + }, + { initialProps: { active: true } } + ); + + rerender({ active: false }); + + expect(document.activeElement).toBe(outsideButton); + document.body.removeChild(outsideButton); + }); + + it('should wrap focus from last to first on Tab', () => { + const lastButton = container.querySelector('#last') as HTMLElement; + lastButton.focus(); + + renderHook(() => { + const ref = useRef(container); + useFocusTrap(ref, true); + return ref; + }); + + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + }); + document.dispatchEvent(tabEvent); + + const firstButton = container.querySelector('#first') as HTMLElement; + expect(document.activeElement).toBe(firstButton); + }); + + it('should wrap focus from first to last on Shift+Tab', () => { + const firstButton = container.querySelector('#first') as HTMLElement; + firstButton.focus(); + + renderHook(() => { + const ref = useRef(container); + useFocusTrap(ref, true); + return ref; + }); + + const shiftTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + }); + document.dispatchEvent(shiftTabEvent); + + const lastButton = container.querySelector('#last') as HTMLElement; + expect(document.activeElement).toBe(lastButton); + }); + + it('should not manage focus when inactive', () => { + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + outsideButton.focus(); + + renderHook(() => { + const ref = useRef(container); + useFocusTrap(ref, false); + return ref; + }); + + expect(document.activeElement).toBe(outsideButton); + document.body.removeChild(outsideButton); + }); + + it('should handle empty focusable elements', () => { + const emptyContainer = document.createElement('div'); + document.body.appendChild(emptyContainer); + + renderHook(() => { + const ref = useRef(emptyContainer); + useFocusTrap(ref, true); + return ref; + }); + + // Should not throw and focus should remain on body or previous element + expect(document.activeElement).toBeDefined(); + + document.body.removeChild(emptyContainer); + }); +}); diff --git a/src/lib/__tests__/mindmap-tree.test.ts b/src/lib/__tests__/mindmap-tree.test.ts new file mode 100644 index 00000000..36390284 --- /dev/null +++ b/src/lib/__tests__/mindmap-tree.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { buildTree, addAriaToNodes } from '../../lib/mindmap-tree'; +import type { Entity, Link } from '../../lib/validation'; + +describe('buildTree', () => { + const mockEntities: Entity[] = [ + { id: '1', name: 'Root', type: 'concept', content: '', created_at: '', updated_at: '' }, + { id: '2', name: 'Child 1', type: 'concept', content: '', created_at: '', updated_at: '' }, + { id: '3', name: 'Child 2', type: 'concept', content: '', created_at: '', updated_at: '' }, + { id: '4', name: 'Grandchild', type: 'concept', content: '', created_at: '', updated_at: '' }, + ]; + + const mockLinks: Link[] = [ + { id: 'l1', source_id: '1', target_id: '2', relation: 'contains', created_at: '' }, + { id: 'l2', source_id: '1', target_id: '3', relation: 'contains', created_at: '' }, + { id: 'l3', source_id: '2', target_id: '4', relation: 'contains', created_at: '' }, + ]; + + it('should return null for empty entities array', () => { + const result = buildTree('1', 0, 5, [], [], 'all'); + expect(result).toBeNull(); + }); + + it('should return single root node for entity with no children', () => { + const result = buildTree('1', 0, 5, mockEntities, [], 'all'); + expect(result).not.toBeNull(); + expect(result?.id).toBe('1'); + expect(result?.topic).toBe('Root'); + expect(result?.children).toHaveLength(0); + }); + + it('should build correct tree structure with parent-child links', () => { + const result = buildTree('1', 0, 5, mockEntities, mockLinks, 'all'); + expect(result).not.toBeNull(); + expect(result?.children).toHaveLength(2); + expect(result?.children[0].topic).toBe('Child 1'); + expect(result?.children[1].topic).toBe('Child 2'); + }); + + it('should respect depth limit', () => { + const result = buildTree('1', 0, 1, mockEntities, mockLinks, 'all'); + expect(result).not.toBeNull(); + expect(result?.children).toHaveLength(2); + // Grandchild should not be included due to depth limit + expect(result?.children[0].children).toHaveLength(0); + }); + + it('should filter by relation type', () => { + const linksWithDifferentRelations: Link[] = [ + { id: 'l1', source_id: '1', target_id: '2', relation: 'contains', created_at: '' }, + { id: 'l2', source_id: '1', target_id: '3', relation: 'related_to', created_at: '' }, + ]; + + const result = buildTree('1', 0, 5, mockEntities, linksWithDifferentRelations, 'contains'); + expect(result).not.toBeNull(); + expect(result?.children).toHaveLength(1); + expect(result?.children[0].topic).toBe('Child 1'); + }); + + it('should handle null entity gracefully', () => { + const result = buildTree('nonexistent', 0, 5, mockEntities, mockLinks, 'all'); + expect(result).toBeNull(); + }); + + it('should handle circular references', () => { + const circularLinks: Link[] = [ + { id: 'l1', source_id: '1', target_id: '2', relation: 'contains', created_at: '' }, + { id: 'l2', source_id: '2', target_id: '1', relation: 'contains', created_at: '' }, + ]; + + // This should not cause infinite recursion + const result = buildTree('1', 0, 10, mockEntities, circularLinks, 'all'); + expect(result).not.toBeNull(); + // The tree should be built with the depth limit preventing infinite recursion + }); + + it('should generate random ID if entity has no ID', () => { + const entitiesWithoutId: Entity[] = [ + { id: '', name: 'No ID Entity', type: 'concept', content: '', created_at: '', updated_at: '' }, + ]; + + const result = buildTree('', 0, 5, entitiesWithoutId, [], 'all'); + expect(result).not.toBeNull(); + expect(result?.id).toMatch(/^node-/); + }); +}); + +describe('addAriaToNodes', () => { + it('should add ARIA attributes to mind map nodes', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + Test Node + + `; + + addAriaToNodes(container); + + const parent = container.querySelector('me-parent'); + expect(parent?.getAttribute('role')).toBe('treeitem'); + expect(parent?.getAttribute('aria-label')).toBe('Test Node'); + }); + + it('should handle empty node list', () => { + const container = document.createElement('div'); + container.innerHTML = '
No nodes
'; + + // Should not throw + addAriaToNodes(container); + }); + + it('should not overwrite existing role attribute', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + Test Node + + `; + + addAriaToNodes(container); + + const parent = container.querySelector('me-parent'); + expect(parent?.getAttribute('role')).toBe('existing-role'); + }); +}); diff --git a/src/lib/llm/__tests__/markdown.test.tsx b/src/lib/llm/__tests__/markdown.test.tsx index 54844a2a..73b7e248 100644 --- a/src/lib/llm/__tests__/markdown.test.tsx +++ b/src/lib/llm/__tests__/markdown.test.tsx @@ -2,127 +2,123 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import MarkdownRenderer from '../markdown'; -// sanitizeHtml (from src/lib/security.ts) only allows: -// b, i, em, strong, a, ul, ol, li, p, br, span, div, h1-h6 -// Tags like ,
,  are stripped but content is preserved.
-
 describe('MarkdownRenderer', () => {
-  it('renders plain text in a paragraph', () => {
-    const { container } = render();
-    expect(container.textContent).toContain('Hello world');
-  });
-
-  it('renders bold text via **syntax**', () => {
-    const { container } = render();
-    const strong = container.querySelector('strong');
-    expect(strong?.textContent).toBe('bold');
-  });
-
-  it('renders italic text via *syntax*', () => {
-    const { container } = render();
-    const em = container.querySelector('em');
-    expect(em?.textContent).toBe('italic');
+  it('should render heading levels h1-h6', () => {
+    const { container } = render();
+    expect(container.querySelector('h1')).toBeTruthy();
+    expect(container.querySelector('h1')?.textContent).toBe('Heading 1');
   });
 
-  it('renders bold italic text via ***syntax***', () => {
-    const { container } = render();
-    const strong = container.querySelector('strong');
-    const em = container.querySelector('em');
-    expect(strong).toBeDefined();
-    expect(em).toBeDefined();
+  it('should render h2', () => {
+    const { container } = render();
+    expect(container.querySelector('h2')).toBeTruthy();
   });
 
-  it('renders inline code content (tag stripped by sanitizer)', () => {
-    const { container } = render();
-    //  is stripped by sanitizeHtml but text content is preserved
-    expect(container.textContent).toContain('console.log');
+  it('should render h3', () => {
+    const { container } = render();
+    expect(container.querySelector('h3')).toBeTruthy();
   });
 
-  it('renders links with target=_blank and rel=noopener', () => {
-    const { container } = render();
-    const link = container.querySelector('a');
-    expect(link?.getAttribute('href')).toBe('https://example.com');
-    expect(link?.getAttribute('target')).toBe('_blank');
-    expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
-    expect(link?.textContent).toBe('click');
-  });
-
-  it('renders strikethrough content (del tag stripped by sanitizer)', () => {
-    const { container } = render();
-    //  is stripped by sanitizeHtml but text content is preserved
-    expect(container.textContent).toContain('deleted');
+  it('should render unordered lists', () => {
+    const { container } = render(
+      
+    );
+    const list = container.querySelector('ul');
+    expect(list).toBeTruthy();
+    const items = container.querySelectorAll('li');
+    expect(items.length).toBe(3);
   });
 
-  it('renders h1 header', () => {
-    const { container } = render();
-    const h1 = container.querySelector('h1');
-    expect(h1?.textContent).toBe('H1');
+  it('should render ordered lists', () => {
+    const { container } = render(
+      
+    );
+    const list = container.querySelector('ol');
+    expect(list).toBeTruthy();
+    const items = container.querySelectorAll('li');
+    expect(items.length).toBe(3);
   });
 
-  it('renders h2 header', () => {
-    const { container } = render();
-    const h2 = container.querySelector('h2');
-    expect(h2?.textContent).toBe('H2');
+  it('should render fenced code blocks', () => {
+    const { container } = render(
+      
+    );
+    const pre = container.querySelector('pre');
+    expect(pre).toBeTruthy();
+    const code = container.querySelector('code');
+    expect(code).toBeTruthy();
   });
 
-  it('renders h3 header', () => {
-    const { container } = render();
-    const h3 = container.querySelector('h3');
-    expect(h3?.textContent).toBe('H3');
+  it('should render inline bold', () => {
+    const { container } = render();
+    const strong = container.querySelector('strong');
+    expect(strong).toBeTruthy();
+    expect(strong?.textContent).toBe('bold text');
   });
 
-  it('renders unordered lists', () => {
-    const { container } = render();
-    const ul = container.querySelector('ul');
-    expect(ul).toBeDefined();
-    const items = container.querySelectorAll('li');
-    expect(items.length).toBe(2);
-    expect(items[0].textContent).toBe('item 1');
-    expect(items[1].textContent).toBe('item 2');
+  it('should render inline italic', () => {
+    const { container } = render();
+    const em = container.querySelector('em');
+    expect(em).toBeTruthy();
+    expect(em?.textContent).toBe('italic text');
   });
 
-  it('renders ordered lists', () => {
-    const { container } = render();
-    const ol = container.querySelector('ol');
-    expect(ol).toBeDefined();
-    const items = container.querySelectorAll('li');
-    expect(items.length).toBe(2);
-    expect(items[0].textContent).toBe('first');
-    expect(items[1].textContent).toBe('second');
+  it('should render strikethrough', () => {
+    const { container } = render();
+    const del = container.querySelector('del');
+    expect(del).toBeTruthy();
+    expect(del?.textContent).toBe('strikethrough');
   });
 
-  it('renders code block content (pre tag stripped by sanitizer)', () => {
-    const { container } = render();
-    // 
 are stripped by sanitizeHtml but text content is preserved
-    expect(container.textContent).toContain('const x = 1;');
+  it('should render inline code', () => {
+    const { container } = render();
+    // The markdown renderer wraps inline code in  tags
+    // Check if the content contains the code element
+    const content = container.querySelector('.markdown-content');
+    expect(content).toBeTruthy();
+    expect(content?.innerHTML).toContain('');
   });
 
-  it('escapes HTML in code blocks to prevent XSS', () => {
-    const { container } = render(alert("xss")```'} />);
-    // The raw HTML should be escaped and never rendered as actual HTML
-    expect(container.innerHTML).not.toContain('" />
+    );
+    const script = container.querySelector('script');
+    expect(script).toBeFalsy();
   });
 
-  it('handles empty content', () => {
+  it('should handle empty input', () => {
     const { container } = render();
-    expect(container.querySelector('.markdown-content')).toBeDefined();
+    expect(container.querySelector('.markdown-content')).toBeTruthy();
   });
 
-  it('renders underscore bold __text__', () => {
-    const { container } = render();
-    const strong = container.querySelector('strong');
-    expect(strong?.textContent).toBe('bold');
+  it('should handle unclosed code blocks', () => {
+    const { container } = render(
+      
+    );
+    // The unclosed code block should still render as a paragraph
+    const p = container.querySelector('p');
+    expect(p).toBeTruthy();
   });
 
-  it('renders underscore italic _text_', () => {
-    const { container } = render();
+  it('should render bold and italic together', () => {
+    const { container } = render(
+      
+    );
+    const strong = container.querySelector('strong');
     const em = container.querySelector('em');
-    expect(em?.textContent).toBe('italic');
+    expect(strong).toBeTruthy();
+    expect(em).toBeTruthy();
   });
 });
diff --git a/src/lib/mindmap-tree.ts b/src/lib/mindmap-tree.ts
new file mode 100644
index 00000000..c7c57aad
--- /dev/null
+++ b/src/lib/mindmap-tree.ts
@@ -0,0 +1,59 @@
+import type { Entity, Link } from './validation';
+
+interface MindMapNode {
+  id: string;
+  topic: string;
+  children: MindMapNode[];
+}
+
+/**
+ * Builds a tree structure from entities and links for mind map visualization.
+ * 
+ * @param currentId - The ID of the current entity to start from
+ * @param depth - Current depth in the tree
+ * @param maxDepth - Maximum depth to traverse
+ * @param entities - Array of all entities
+ * @param links - Array of all links between entities
+ * @param relationFilter - Filter for link relations ('all' for no filter)
+ * @returns Tree node or null if entity not found or depth exceeded
+ */
+export function buildTree(
+  currentId: string,
+  depth: number,
+  maxDepth: number,
+  entities: Entity[],
+  links: Link[],
+  relationFilter: string,
+): MindMapNode | null {
+  const entity = entities.find(e => e.id === currentId);
+  if (!entity || depth > maxDepth) return null;
+
+  const childrenLinks = links.filter(l =>
+    l.source_id === currentId &&
+    (relationFilter === 'all' || l.relation === relationFilter)
+  );
+
+  return {
+    id: entity.id || `node-${Math.random()}`,
+    topic: entity.name,
+    children: childrenLinks
+      .map(l => buildTree(l.target_id, depth + 1, maxDepth, entities, links, relationFilter))
+      .filter((n): n is MindMapNode => n !== null)
+  };
+}
+
+/**
+ * Adds ARIA attributes to mind map nodes for accessibility.
+ * 
+ * @param container - The container element containing mind map nodes
+ */
+export function addAriaToNodes(container: HTMLElement): void {
+  const topics = container.querySelectorAll('me-tpc');
+  topics.forEach(tpc => {
+    const parent = tpc.closest('me-parent');
+    if (parent && !parent.hasAttribute('role')) {
+      parent.setAttribute('role', 'treeitem');
+      parent.setAttribute('aria-label', tpc.textContent?.trim() || 'Mind map node');
+    }
+  });
+}
diff --git a/src/lib/security.ts b/src/lib/security.ts
index 0d959903..9bf78f6d 100644
--- a/src/lib/security.ts
+++ b/src/lib/security.ts
@@ -2,7 +2,7 @@ import DOMPurify from 'dompurify';
 
 export function sanitizeHtml(html: string): string {
   return DOMPurify.sanitize(html, {
-    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'del'],
     ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
     ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
   });
diff --git a/vitest.config.ts b/vitest.config.ts
index 8f57d022..add5cd2a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -39,10 +39,10 @@ export default defineConfig({
         'src/features/export/pdf-styles.ts',
       ],
       thresholds: {
-        branches: 30,
-        functions: 35,
-        lines: 40,
-        statements: 40,
+        branches: 33,
+        functions: 38,
+        lines: 43,
+        statements: 42,
       },
     },
   },