feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support

This commit is contained in:
2025-10-14 10:41:15 -04:00
parent b359e8ab8e
commit 32a9998b40
71 changed files with 11544 additions and 435 deletions
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
test.describe('Excalidraw Integration', () => {
test('should load and display Excalidraw editor', async ({ page }) => {
await page.goto('http://localhost:4200');
// Wait for app to load
await page.waitForSelector('body', { timeout: 10000 });
// Check if test drawing file exists in file list
const testFile = page.locator('text=test-drawing');
if (await testFile.isVisible({ timeout: 5000 }).catch(() => false)) {
await testFile.click();
// Wait for Excalidraw editor to load
await page.waitForSelector('excalidraw-editor', { timeout: 10000 });
// Verify editor is present
const editor = page.locator('excalidraw-editor');
await expect(editor).toBeVisible();
}
});
test('should handle file API with query params', async ({ page, request }) => {
// Test GET endpoint with query param
const response = await request.get('http://localhost:4200/api/files', {
params: { path: 'test-drawing.excalidraw.md' }
});
if (response.ok()) {
const data = await response.json();
expect(data).toHaveProperty('elements');
expect(Array.isArray(data.elements)).toBeTruthy();
}
});
test('should parse Obsidian format correctly', async ({ request }) => {
// Test that the backend can parse Obsidian format
const response = await request.get('http://localhost:4200/api/files', {
params: { path: 'test-drawing.excalidraw.md' }
});
if (response.ok()) {
const data = await response.json();
// Verify structure
expect(data).toHaveProperty('elements');
expect(data).toHaveProperty('appState');
expect(data).toHaveProperty('files');
// Verify types
expect(Array.isArray(data.elements)).toBeTruthy();
expect(typeof data.appState).toBe('object');
expect(typeof data.files).toBe('object');
}
});
});
+208
View File
@@ -0,0 +1,208 @@
import { test, expect } from '@playwright/test';
test.describe('Meilisearch Search Integration', () => {
test.beforeEach(async ({ page }) => {
// Navigate to the app
await page.goto('http://localhost:4000');
// Wait for the app to load
await page.waitForLoadState('networkidle');
});
test('should perform search via Meilisearch backend', async ({ page }) => {
// Open search panel (assuming there's a search button or input)
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
// Type search query
await searchInput.fill('test');
// Wait for debounce (300ms) + network request
await page.waitForTimeout(500);
// Check that search results are displayed
const results = page.locator('[class*="search-result"]').first();
await expect(results).toBeVisible({ timeout: 5000 });
});
test('should not freeze UI during search typing', async ({ page }) => {
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
// Type quickly to test debounce
await searchInput.type('angular', { delay: 50 });
// UI should remain responsive
const isEnabled = await searchInput.isEnabled();
expect(isEnabled).toBe(true);
// Wait for debounced search
await page.waitForTimeout(400);
});
test('should use Meilisearch API endpoint', async ({ page }) => {
// Listen for API calls
const apiCalls: string[] = [];
page.on('request', request => {
if (request.url().includes('/api/search')) {
apiCalls.push(request.url());
}
});
// Perform search
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
await searchInput.fill('note');
await searchInput.press('Enter');
// Wait for API call
await page.waitForTimeout(1000);
// Verify API was called
expect(apiCalls.length).toBeGreaterThan(0);
expect(apiCalls[0]).toContain('/api/search?q=note');
});
test('should display search results quickly', async ({ page }) => {
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
const startTime = Date.now();
// Perform search
await searchInput.fill('test');
await searchInput.press('Enter');
// Wait for results
await page.waitForSelector('[class*="search-result"]', { timeout: 2000 });
const endTime = Date.now();
const duration = endTime - startTime;
// Search should complete in less than 2 seconds
expect(duration).toBeLessThan(2000);
console.log(`Search completed in ${duration}ms`);
});
test('should handle empty search gracefully', async ({ page }) => {
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
await searchInput.fill('xyzabc123notfound');
await searchInput.press('Enter');
// Wait for response
await page.waitForTimeout(1000);
// Should show "no results" message
const noResults = page.locator('text=/No results|Aucun résultat/i').first();
await expect(noResults).toBeVisible({ timeout: 3000 });
});
test('should support Obsidian search operators', async ({ page }) => {
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
// Test path: operator
await searchInput.fill('path:docs');
await searchInput.press('Enter');
await page.waitForTimeout(500);
// Clear and test tag: operator
await searchInput.clear();
await searchInput.fill('tag:important');
await searchInput.press('Enter');
await page.waitForTimeout(500);
// Should not crash
const isEnabled = await searchInput.isEnabled();
expect(isEnabled).toBe(true);
});
test('should debounce live search during typing', async ({ page }) => {
// Listen for API calls
let apiCallCount = 0;
page.on('request', request => {
if (request.url().includes('/api/search')) {
apiCallCount++;
}
});
const searchInput = page.locator('input[type="text"]').first();
await searchInput.click();
// Type slowly (each char triggers potential search)
await searchInput.type('angular', { delay: 100 });
// Wait for debounce to settle
await page.waitForTimeout(500);
// Should have made fewer API calls than characters typed (due to debounce)
// "angular" = 7 chars, but debounce should reduce calls
expect(apiCallCount).toBeLessThan(7);
console.log(`API calls made: ${apiCallCount} (debounced from 7 chars)`);
});
});
test.describe('Meilisearch Backend API', () => {
test('should return search results from /api/search', async ({ request }) => {
const response = await request.get('http://localhost:4000/api/search?q=test&limit=5');
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data).toHaveProperty('hits');
expect(data).toHaveProperty('processingTimeMs');
expect(data).toHaveProperty('query');
expect(data.query).toBe('test');
console.log(`Meilisearch processed search in ${data.processingTimeMs}ms`);
});
test('should support highlighting', async ({ request }) => {
const response = await request.get('http://localhost:4000/api/search?q=note&highlight=true&limit=3');
expect(response.ok()).toBeTruthy();
const data = await response.json();
if (data.hits.length > 0) {
// Check if highlighting is present
const firstHit = data.hits[0];
expect(firstHit).toHaveProperty('_formatted');
}
});
test('should handle pagination', async ({ request }) => {
const response1 = await request.get('http://localhost:4000/api/search?q=note&limit=2&offset=0');
const response2 = await request.get('http://localhost:4000/api/search?q=note&limit=2&offset=2');
expect(response1.ok()).toBeTruthy();
expect(response2.ok()).toBeTruthy();
const data1 = await response1.json();
const data2 = await response2.json();
// Results should be different (different pages)
if (data1.hits.length > 0 && data2.hits.length > 0) {
expect(data1.hits[0].id).not.toBe(data2.hits[0].id);
}
});
test('should be fast (< 100ms)', async ({ request }) => {
const startTime = Date.now();
const response = await request.get('http://localhost:4000/api/search?q=angular&limit=10');
const endTime = Date.now();
const duration = endTime - startTime;
expect(response.ok()).toBeTruthy();
const data = await response.json();
// Total time (network + processing) should be < 100ms for localhost
expect(duration).toBeLessThan(100);
console.log(`Total search time: ${duration}ms (Meili: ${data.processingTimeMs}ms)`);
});
});