fix(ssr): resolve EventSource SSR violations and implement best practices

- Fix EventSource is not defined error in queue dashboard
- Add browser guards for all EventSource usage
- Replace static constants (EventSource.OPEN/CLOSED) with numeric values
- Fix setInterval SSR violation in LLM health indicator
- Replace $effect anti-pattern with onMount in share page
- Add comprehensive SvelteKit SSR best practices documentation
- Add SSR audit and testing verification

All changes follow SvelteKit best practices and are verified against
official documentation. Production build succeeds with no SSR errors.

Closes: FixEventSourceSSR
See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
Giancarmine Salucci
2025-12-22 03:00:29 +01:00
parent 35d6f6e40a
commit 8545744bb1
47 changed files with 12827 additions and 363 deletions

View File

@@ -0,0 +1,250 @@
/**
* Integration tests for QueueProcessor
*
* Tests the processor's ability to handle queue items through mocked dependencies.
* The QueueProcessor auto-starts, so these tests verify actual processing behavior.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { queueManager } from '$lib/server/queue/QueueManager';
// Mock queueConfig BEFORE importing QueueProcessor
vi.mock('$lib/server/queue/config', () => ({
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: {
enabled: true,
token: 'test-token',
serverUrl: 'http://localhost:8080'
},
push: {
vapidPublicKey: 'test-public-key',
vapidPrivateKey: 'test-private-key'
}
}
}));
// Mock external dependencies BEFORE importing QueueProcessor
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
}));
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
})
}));
vi.mock('$lib/server/tandoor', () => ({
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
success: true,
recipeId: 999
}),
uploadRecipeImage: vi.fn().mockResolvedValue({
success: true
})
}));
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import * as configModule from '$lib/server/queue/config';
// Import processor AFTER mocks - it will auto-start (imported for side effects)
import '$lib/server/queue/QueueProcessor';
describe('QueueProcessor Integration Tests', () => {
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach(item => queueManager.remove(item.id));
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
afterEach(async () => {
// Wait for any pending processing to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
it('should process item through all phases when Tandoor is configured', async () => {
// Set up successful mocks
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe instructions here',
thumbnail: 'https://example.com/thumb.jpg'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Test Recipe',
ingredients: ['flour', 'eggs'],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 123
});
// Enqueue (processor is already running from auto-start)
// Note: Tandoor is enabled in the mocked config
const item = queueManager.enqueue('https://instagram.com/p/test-tandoor');
// Wait for processing to complete - increased timeout
await new Promise((resolve) => setTimeout(resolve, 1000));
const updated = queueManager.get(item.id);
// Verify success
expect(updated?.status).toBe('success');
expect(updated?.extractedText).toBe('Recipe instructions here');
expect(updated?.recipe?.name).toBe('Test Recipe');
expect(updated?.tandoorRecipeId).toBe(123);
// Verify all functions were called
expect(extractTextAndThumbnail).toHaveBeenCalled();
expect(extractRecipe).toHaveBeenCalled();
expect(uploadRecipeWithIngredientsDTO).toHaveBeenCalled();
}, 10000); // Increase timeout for processing
it('should skip Tandoor upload when not configured', async () => {
// Temporarily disable Tandoor for this test
const originalConfig = { ...configModule.queueConfig };
vi.spyOn(configModule, 'queueConfig', 'get').mockReturnValue({
...originalConfig,
tandoor: {
enabled: false,
token: null,
serverUrl: null
}
});
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
ingredients: [],
steps: [],
description: ''
});
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should still succeed without Tandoor
expect(updated?.status).toBe('success');
expect(updated?.recipe?.name).toBe('No Tandoor Recipe');
expect(uploadRecipeWithIngredientsDTO).not.toHaveBeenCalled();
// Restore mock
vi.restoreAllMocks();
}, 10000);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
new Error('Network timeout')
);
const item = queueManager.enqueue('https://instagram.com/p/error');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as unhealthy (recoverable)
expect(updated?.status).toBe('unhealthy');
expect(updated?.error?.message).toContain('timeout');
}, 10000);
it('should handle parsing failure', async () => {
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Not a recipe',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue(null);
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as error (non-recoverable - no recipe found)
expect(updated?.status).toBe('error');
expect(updated?.error?.message).toContain('recipe');
}, 10000);
it('should process multiple items respecting concurrency', async () => {
// Set up mocks with delay to observe concurrency
vi.mocked(extractTextAndThumbnail).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return { bodyText: 'text', thumbnail: null };
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Concurrent Recipe',
ingredients: [],
steps: [],
description: ''
});
// Enqueue 3 items (Tandoor enabled by default in config mock)
queueManager.enqueue('https://instagram.com/p/item1');
queueManager.enqueue('https://instagram.com/p/item2');
queueManager.enqueue('https://instagram.com/p/item3');
// Wait a bit for processor to start working
await new Promise((resolve) => setTimeout(resolve, 150));
const items = queueManager.getAll();
const inProgress = items.filter(i => i.status === 'in_progress');
// With concurrency=2, should have max 2 in progress at once
expect(inProgress.length).toBeLessThanOrEqual(2);
// Wait for all to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
const final = queueManager.getAll();
const completed = final.filter(i => i.status === 'success');
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
});