This commit is contained in:
Giancarmine Salucci
2026-02-18 01:21:44 +01:00
parent 54321fd7c9
commit 49bccf8f15
84 changed files with 14474 additions and 13925 deletions

View File

@@ -3,10 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",

View File

@@ -5,6 +5,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
## 🚀 Features
### Core Functionality
- **Async Queue Processing**: Fire-and-forget recipe extraction with background processing
- **Real-time Updates**: Server-Sent Events for live progress tracking
- **Push Notifications**: Background notifications when recipes complete
@@ -13,6 +14,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
- **PWA Support**: Installable Progressive Web App with offline capabilities
### User Experience
- **Queue Dashboard**: Monitor all recipe extractions in real-time
- **Share Integration**: Browser share target for easy URL submission
- **Responsive Design**: Works on desktop, tablet, and mobile
@@ -20,6 +22,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
- **Progress Tracking**: Visual progress through extraction phases
### Technical Architecture
- **SvelteKit Frontend**: Modern reactive UI with TypeScript
- **Hexagonal Architecture**: Clean separation of concerns
- **In-Memory Queue**: High-performance processing with configurable concurrency
@@ -29,6 +32,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
## 📋 API Endpoints
### Queue Management
- `POST /api/queue` - Enqueue Instagram URL for processing
- `GET /api/queue` - List queue items with filtering and pagination
- `GET /api/queue/{id}` - Get specific queue item details
@@ -36,17 +40,20 @@ A modern web application that extracts recipes from Instagram posts and saves th
- `GET /api/queue/stream` - Server-Sent Events for real-time updates
### Push Notifications
- `POST /api/notifications/subscribe` - Subscribe to push notifications
- `DELETE /api/notifications/subscribe` - Unsubscribe from notifications
- `GET /api/notifications/vapid-key` - Get VAPID public key
### Legacy Endpoints (Deprecated)
- ~~`POST /api/extract`~~ - Use `/api/queue` instead
- ~~`GET /api/extract-stream`~~ - Use `/api/queue/stream` instead
## 🛠 Development Setup
### Prerequisites
- Node.js 18+
- npm or pnpm
- Tandoor Recipe Manager instance (optional)
@@ -79,6 +86,7 @@ open https://localhost:5173
```
The app runs on HTTPS by default for:
- Service worker support (required for PWA)
- Push notifications
- Browser share target API
@@ -89,6 +97,7 @@ The app runs on HTTPS by default for:
The application uses HTTPS in development with SSL certificates signed by an external Caddy CA container. The current certificate is valid until **December 20, 2035** (10 years).
**Certificate Information:**
- Location: `.ssl/` directory
- CA Certificate: `.ssl/root.crt` (already trusted on the system)
- Server Certificate: `.ssl/localhost.crt`
@@ -97,18 +106,21 @@ The application uses HTTPS in development with SSL certificates signed by an ext
Since the Caddy CA is already trusted on the system, the certificate should work without additional trust steps. If you encounter browser warnings:
**Linux (Ubuntu/Debian):**
```bash
sudo cp .ssl/root.crt /usr/local/share/ca-certificates/caddy-local.crt
sudo update-ca-certificates
```
**Chrome/Chromium:**
1. Go to `chrome://settings/certificates`
2. Click "Authorities" → "Import"
3. Select `.ssl/root.crt`
4. Check "Trust this certificate for identifying websites"
**Checking Certificate Expiration:**
```bash
openssl x509 -enddate -noout -in .ssl/localhost.crt
```
@@ -220,6 +232,7 @@ To enable web push notifications:
## 🏗 Architecture Overview
### Queue System
```
User submits URL → Queue Manager → Queue Processor
@@ -257,6 +270,7 @@ npm run test:watch
```
Test Coverage:
- **138 total tests** covering all major components
- Queue Manager: 28 tests
- Queue Processor: 5 integration tests
@@ -279,11 +293,13 @@ npm run preview
### Deployment
The app is built as a Node.js application with the following outputs:
- `/.svelte-kit/output/server/` - Server bundle
- `/.svelte-kit/output/client/` - Static assets
- `/build/` - Adapter output
Deploy the server bundle with:
```bash
node build/index.js
```
@@ -307,6 +323,7 @@ CMD ["node", "build"]
The app was migrated from a synchronous extraction system to an async queue-based system:
**Before (Synchronous)**:
- User waited for entire extraction process to complete
- No progress tracking during processing
- No retry capability for failures
@@ -314,6 +331,7 @@ The app was migrated from a synchronous extraction system to an async queue-base
- Limited error handling
**After (Async Queue)**:
- Fire-and-forget: submit URL and redirect immediately
- Real-time progress tracking via SSE
- Comprehensive retry system for failures
@@ -324,12 +342,14 @@ The app was migrated from a synchronous extraction system to an async queue-base
### API Migration
**Old Synchronous Endpoints** (Deprecated):
```bash
POST /api/extract # Submit URL and wait for completion
GET /api/extract-stream # Long-polling for progress
```
**New Queue Endpoints**:
```bash
POST /api/queue # Submit URL, get queue ID immediately
GET /api/queue # List all queue items
@@ -351,6 +371,7 @@ If migrating from the old system:
### Backward Compatibility
The legacy endpoints are still available but deprecated:
- They will return `410 Gone` status with migration instructions
- Support will be removed in a future version
- All new development should use the queue endpoints
@@ -383,4 +404,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail
- [Tandoor Recipe Manager](https://docs.tandoor.dev/) - Recipe management system
- [Workbox](https://developers.google.com/web/tools/workbox) - PWA capabilities
- [fastq](https://github.com/mcollina/fastq) - High-performance queue processing

View File

@@ -4,7 +4,7 @@ services:
container_name: insta-recipe
network_mode: host
ports:
- "3000:3000"
- '3000:3000'
environment:
# LLM Configuration (Required)
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
@@ -40,7 +40,13 @@ services:
- ./secrets:/app/secrets
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
test:
[
'CMD',
'node',
'-e',
"fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -2,7 +2,7 @@ services:
app:
build: .
ports:
- "5173:5173"
- '5173:5173'
environment:
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
- OPENAI_BASE_URL=http://ollama:11434/v1
@@ -18,7 +18,7 @@ services:
playwright-service:
build: ./playwright-service
ipc: host
ports: ["3000:3000"]
ports: ['3000:3000']
environment:
- DISPLAY=:99
security_opt:
@@ -26,7 +26,7 @@ services:
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
ports: ['11434:11434']
volumes:
- ollama_data:/root/.ollama

View File

@@ -5,6 +5,7 @@ This document describes the InstaRecipe API endpoints for the async queue-based
## Base URL
All API endpoints are relative to your InstaRecipe instance:
```
https://your-instarecipe-instance.com/api
```
@@ -23,13 +24,16 @@ All endpoints return standardized error responses:
```json
{
"error": "Error type",
"message": "Human-readable error message",
"details": { /* Additional error context */ }
"error": "Error type",
"message": "Human-readable error message",
"details": {
/* Additional error context */
}
}
```
HTTP status codes follow REST conventions:
- `200` - Success
- `201` - Created
- `400` - Bad Request (invalid input)
@@ -45,13 +49,15 @@ HTTP status codes follow REST conventions:
Enqueue an Instagram URL for async processing.
**Request:**
```json
{
"url": "https://instagram.com/p/abc123"
"url": "https://instagram.com/p/abc123"
}
```
**Supported URL Formats:**
- Posts: `https://instagram.com/p/{post-id}`
- Posts (www): `https://www.instagram.com/p/{post-id}`
- Reels: `https://instagram.com/reel/{reel-id}`
@@ -59,12 +65,14 @@ Enqueue an Instagram URL for async processing.
- With query parameters: `https://instagram.com/reel/{reel-id}?utm_source=share`
**URL Requirements:**
- Must use HTTPS protocol
- Hostname must be `instagram.com` or `www.instagram.com`
- Any Instagram path is accepted (posts, reels, IGTV, etc.)
- Query parameters and hash fragments are allowed
**Examples:**
```json
// Post URL
{ "url": "https://instagram.com/p/ABC123" }
@@ -77,34 +85,36 @@ Enqueue an Instagram URL for async processing.
```
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link",
"status": "pending",
"phases": [
{
"name": "extraction",
"status": "pending",
"progress": 0
},
{
"name": "parsing",
"status": "pending",
"progress": 0
},
{
"name": "uploading",
"status": "pending",
"progress": 0
}
],
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:00Z"
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link",
"status": "pending",
"phases": [
{
"name": "extraction",
"status": "pending",
"progress": 0
},
{
"name": "parsing",
"status": "pending",
"progress": 0
},
{
"name": "uploading",
"status": "pending",
"progress": 0
}
],
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:00Z"
}
```
**Errors:**
- `400` - Invalid URL format (not a valid URL)
- `400` - URL must use HTTPS protocol
- `400` - URL must be from instagram.com domain
@@ -115,6 +125,7 @@ Enqueue an Instagram URL for async processing.
List queue items with optional filtering, pagination, and sorting.
**Query Parameters:**
- `status` (optional): Filter by status (`pending`, `in_progress`, `success`, `error`, `unhealthy`)
- `limit` (optional): Number of items to return (default: 50, max: 100)
- `offset` (optional): Number of items to skip (default: 0)
@@ -122,6 +133,7 @@ List queue items with optional filtering, pagination, and sorting.
- `order` (optional): Sort order (`asc`, `desc`) (default: `desc`)
**Examples:**
```bash
GET /api/queue # All items
GET /api/queue?status=error # Failed items only
@@ -130,67 +142,68 @@ GET /api/queue?sort=status&order=asc # Sort by status
```
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/p/abc123",
"status": "success",
"phases": [
{
"name": "extraction",
"status": "completed",
"startedAt": "2024-12-21T10:30:01Z",
"completedAt": "2024-12-21T10:30:15Z",
"progress": 100
},
{
"name": "parsing",
"status": "completed",
"startedAt": "2024-12-21T10:30:15Z",
"completedAt": "2024-12-21T10:30:25Z",
"progress": 100
},
{
"name": "uploading",
"status": "completed",
"startedAt": "2024-12-21T10:30:25Z",
"completedAt": "2024-12-21T10:30:30Z",
"progress": 100
}
],
"results": {
"recipe": {
"name": "Chocolate Chip Cookies",
"description": "Delicious homemade cookies",
"servings": 24,
"ingredients": [
{
"food": "flour",
"amount": 2.25,
"unit": "cups"
}
],
"steps": [
{
"instruction": "Preheat oven to 375°F",
"time": 5
}
],
"keywords": ["cookies", "dessert", "chocolate"],
"image": "https://instagram.com/image.jpg"
},
"tandoorUrl": "https://tandoor.example.com/recipe/123",
"extractedText": "Raw extracted text...",
"thumbnail": "https://instagram.com/thumbnail.jpg"
},
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:30Z"
}
],
"total": 42,
"hasMore": true
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/p/abc123",
"status": "success",
"phases": [
{
"name": "extraction",
"status": "completed",
"startedAt": "2024-12-21T10:30:01Z",
"completedAt": "2024-12-21T10:30:15Z",
"progress": 100
},
{
"name": "parsing",
"status": "completed",
"startedAt": "2024-12-21T10:30:15Z",
"completedAt": "2024-12-21T10:30:25Z",
"progress": 100
},
{
"name": "uploading",
"status": "completed",
"startedAt": "2024-12-21T10:30:25Z",
"completedAt": "2024-12-21T10:30:30Z",
"progress": 100
}
],
"results": {
"recipe": {
"name": "Chocolate Chip Cookies",
"description": "Delicious homemade cookies",
"servings": 24,
"ingredients": [
{
"food": "flour",
"amount": 2.25,
"unit": "cups"
}
],
"steps": [
{
"instruction": "Preheat oven to 375°F",
"time": 5
}
],
"keywords": ["cookies", "dessert", "chocolate"],
"image": "https://instagram.com/image.jpg"
},
"tandoorUrl": "https://tandoor.example.com/recipe/123",
"extractedText": "Raw extracted text...",
"thumbnail": "https://instagram.com/thumbnail.jpg"
},
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:30Z"
}
],
"total": 42,
"hasMore": true
}
```
@@ -199,12 +212,14 @@ GET /api/queue?sort=status&order=asc # Sort by status
Get details for a specific queue item.
**Path Parameters:**
- `id`: Queue item UUID
**Response (200 OK):**
Returns the same queue item structure as in the list response.
**Errors:**
- `400` - Invalid UUID format
- `404` - Queue item not found
@@ -213,22 +228,25 @@ Returns the same queue item structure as in the list response.
Retry a failed queue item. Only items with `error` or `unhealthy` status can be retried.
**Path Parameters:**
- `id`: Queue item UUID
**Response (200 OK):**
```json
{
"success": true,
"message": "Item queued for retry",
"item": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"updatedAt": "2024-12-21T11:00:00Z"
}
"success": true,
"message": "Item queued for retry",
"item": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"updatedAt": "2024-12-21T11:00:00Z"
}
}
```
**Errors:**
- `400` - Invalid UUID format
- `404` - Queue item not found
- `409` - Cannot retry item with current status (only `error` and `unhealthy` can be retried)
@@ -240,10 +258,12 @@ Retry a failed queue item. Only items with `error` or `unhealthy` status can be
Server-Sent Events (SSE) endpoint for real-time queue updates.
**Query Parameters:**
- `itemId` (optional): Filter updates for specific item
- `status` (optional): Filter updates by status
**Headers:**
```
Accept: text/event-stream
Cache-Control: no-cache
@@ -253,14 +273,18 @@ Cache-Control: no-cache
SSE stream with the following event types:
#### connection
Sent when connection is established:
```
event: connection
data: {"message": "Connected to queue stream", "timestamp": "2024-12-21T10:30:00Z"}
```
#### queue-update
Sent when queue item status changes:
```
event: queue-update
data: {
@@ -279,7 +303,9 @@ data: {
```
#### ping
Keep-alive ping sent every 30 seconds:
```
event: ping
data: {"timestamp": "2024-12-21T10:30:30Z"}
@@ -288,30 +314,32 @@ data: {"timestamp": "2024-12-21T10:30:30Z"}
**Usage Examples:**
**JavaScript:**
```javascript
const eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('connection', (event) => {
console.log('Connected:', JSON.parse(event.data));
console.log('Connected:', JSON.parse(event.data));
});
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
console.log('Queue update:', update);
updateUI(update);
const update = JSON.parse(event.data);
console.log('Queue update:', update);
updateUI(update);
});
eventSource.addEventListener('ping', (event) => {
console.log('Keep-alive ping');
console.log('Keep-alive ping');
});
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Reconnect logic here
console.error('SSE error:', error);
// Reconnect logic here
};
```
**curl:**
```bash
curl -N -H "Accept: text/event-stream" \
"https://your-instance.com/api/queue/stream?itemId=550e8400-e29b-41d4-a716-446655440000"
@@ -324,10 +352,11 @@ curl -N -H "Accept: text/event-stream" \
Get the VAPID public key required for push notification subscriptions.
**Response (200 OK):**
```json
{
"publicKey": "BDummyPublicKeyForDevelopment...",
"applicationServerKey": "BDummyPublicKeyForDevelopment..."
"publicKey": "BDummyPublicKeyForDevelopment...",
"applicationServerKey": "BDummyPublicKeyForDevelopment..."
}
```
@@ -336,29 +365,32 @@ Get the VAPID public key required for push notification subscriptions.
Subscribe to push notifications for queue processing updates.
**Request:**
```json
{
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BIBn3E_YUVpW5f6_Eq_GH...",
"auth": "tBiH_Y1nPSuVh7TRMhcf..."
}
},
"clientId": "unique-client-identifier"
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BIBn3E_YUVpW5f6_Eq_GH...",
"auth": "tBiH_Y1nPSuVh7TRMhcf..."
}
},
"clientId": "unique-client-identifier"
}
```
**Response (200 OK):**
```json
{
"success": true,
"message": "Successfully subscribed to push notifications",
"subscriptionCount": 5
"success": true,
"message": "Successfully subscribed to push notifications",
"subscriptionCount": 5
}
```
**Errors:**
- `400` - Invalid subscription object or missing clientId
### DELETE /api/notifications/subscribe
@@ -366,18 +398,20 @@ Subscribe to push notifications for queue processing updates.
Unsubscribe from push notifications.
**Request:**
```json
{
"clientId": "unique-client-identifier"
"clientId": "unique-client-identifier"
}
```
**Response (200 OK):**
```json
{
"success": true,
"message": "Successfully unsubscribed from push notifications",
"subscriptionCount": 4
"success": true,
"message": "Successfully unsubscribed from push notifications",
"subscriptionCount": 4
}
```
@@ -390,18 +424,19 @@ Unsubscribe from push notifications.
This synchronous extraction endpoint is deprecated. Use `POST /api/queue` instead.
**Migration:**
```javascript
// ❌ Old synchronous approach
const response = await fetch('/api/extract', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const result = await response.json(); // Wait 30-60 seconds
// ✅ New async queue approach
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const queueItem = await response.json(); // Immediate response
```
@@ -413,17 +448,18 @@ const queueItem = await response.json(); // Immediate response
This SSE endpoint is deprecated. Use `POST /api/queue` + `GET /api/queue/stream` instead.
**Migration:**
```javascript
// ❌ Old approach
const response = await fetch('/api/extract-stream', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
// ✅ New approach
const queueResponse = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const item = await queueResponse.json();
@@ -436,28 +472,28 @@ const eventSource = new EventSource(`/api/queue/stream?itemId=${item.id}`);
```typescript
interface QueueItem {
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string; // ISO 8601 timestamp
completedAt?: string; // ISO 8601 timestamp
progress?: number; // 0-100
}>;
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string; // ISO 8601 timestamp
completedAt?: string; // ISO 8601 timestamp
progress?: number; // 0-100
}>;
results?: {
recipe?: Recipe; // Structured recipe data
tandoorUrl?: string; // Tandoor recipe URL
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
results?: {
recipe?: Recipe; // Structured recipe data
tandoorUrl?: string; // Tandoor recipe URL
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
error?: string; // Error message
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
error?: string; // Error message
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
@@ -465,32 +501,33 @@ interface QueueItem {
```typescript
interface Recipe {
name: string;
description?: string;
servings?: number;
prepTime?: number; // Minutes
cookTime?: number; // Minutes
totalTime?: number; // Minutes
name: string;
description?: string;
servings?: number;
prepTime?: number; // Minutes
cookTime?: number; // Minutes
totalTime?: number; // Minutes
ingredients: Array<{
food: string;
amount?: number;
unit?: string;
}>;
ingredients: Array<{
food: string;
amount?: number;
unit?: string;
}>;
steps: Array<{
instruction: string;
time?: number; // Minutes
}>;
steps: Array<{
instruction: string;
time?: number; // Minutes
}>;
keywords?: string[]; // Recipe tags
image?: string; // Image URL
nutrition?: { // Nutritional information
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
};
keywords?: string[]; // Recipe tags
image?: string; // Image URL
nutrition?: {
// Nutritional information
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
};
}
```
@@ -517,59 +554,58 @@ When implementing clients, consider these error recovery strategies:
```javascript
async function processInstagramUrl(url) {
try {
// 1. Enqueue URL
const queueResponse = await fetch('/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
try {
// 1. Enqueue URL
const queueResponse = await fetch('/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const queueItem = await queueResponse.json();
console.log('Enqueued:', queueItem.id);
const queueItem = await queueResponse.json();
console.log('Enqueued:', queueItem.id);
// 2. Listen for real-time updates
const eventSource = new EventSource(`/api/queue/stream?itemId=${queueItem.id}`);
// 2. Listen for real-time updates
const eventSource = new EventSource(`/api/queue/stream?itemId=${queueItem.id}`);
return new Promise((resolve, reject) => {
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
return new Promise((resolve, reject) => {
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
if (update.status === 'success') {
eventSource.close();
resolve(update.results);
} else if (update.status === 'error') {
eventSource.close();
reject(new Error(update.error));
}
if (update.status === 'success') {
eventSource.close();
resolve(update.results);
} else if (update.status === 'error') {
eventSource.close();
reject(new Error(update.error));
}
// Handle progress updates
console.log('Progress:', update.progress);
});
// Handle progress updates
console.log('Progress:', update.progress);
});
eventSource.onerror = (error) => {
eventSource.close();
reject(error);
};
});
} catch (error) {
console.error('Processing failed:', error);
throw error;
}
eventSource.onerror = (error) => {
eventSource.close();
reject(error);
};
});
} catch (error) {
console.error('Processing failed:', error);
throw error;
}
}
// Usage
processInstagramUrl('https://instagram.com/p/abc123')
.then(results => {
console.log('Recipe extracted:', results.recipe);
if (results.tandoorUrl) {
console.log('Uploaded to Tandoor:', results.tandoorUrl);
}
})
.catch(error => {
console.error('Extraction failed:', error.message);
});
.then((results) => {
console.log('Recipe extracted:', results.recipe);
if (results.tandoorUrl) {
console.log('Uploaded to Tandoor:', results.tandoorUrl);
}
})
.catch((error) => {
console.error('Extraction failed:', error.message);
});
```
For more examples and integration guides, see the [Migration Documentation](/docs/MIGRATION.md).

View File

@@ -91,21 +91,27 @@ insta-recipe/
## Key Directories
### `/src/lib/server/`
Server-side business logic following Hexagonal Architecture principles. Contains domain logic, adapters for external systems (Instagram, Tandoor, LLM), and port definitions.
### `/src/lib/client/`
Client-side utilities for PWA features (push notifications, install prompts, service worker messaging).
### `/src/routes/api/`
RESTful API endpoints implemented as SvelteKit server routes. Each directory contains `+server.ts` files exporting HTTP verb handlers.
### `/src/routes/share/`
Share target page allowing users to share Instagram URLs directly from their browser or mobile apps.
### `/src/lib/server/queue/`
Queue management system with in-memory storage, processor workers, and type definitions.
### `/docs/`
Comprehensive documentation including plans, outcomes, API specs, and migration guides.
---
@@ -113,33 +119,43 @@ Comprehensive documentation including plans, outcomes, API specs, and migration
## Design Patterns
### Singleton Pattern
Used for shared service instances:
- `QueueManager` (`queueManager` exported instance)
- `QueueProcessor` (`queueProcessor` exported instance)
- `PushNotificationService` (`pushNotificationService` exported instance)
- `ServiceWorkerMessageHandler` (`serviceWorkerMessageHandler` exported instance)
### Factory Pattern
Used for creating configured instances:
- `createLLM()` - Creates OpenAI client with environment configuration
- `createBrowserContext()` - Creates Playwright browser context with options
- `initializeBrowser()` - Initializes Chromium browser instance
### Observer Pattern
Implemented in QueueManager for real-time updates:
- Subscribers receive notifications on queue item changes
- Server-Sent Events (SSE) stream queue updates to clients
- Push notifications notify users of completion events
### Adapter Pattern (Hexagonal Architecture)
External systems accessed via adapters:
- **Instagram Adapter**: `extraction.ts` - Extracts content via Playwright
- **LLM Adapter**: `llm.ts`, `parser.ts` - Recipe parsing via OpenAI
- **Tandoor Adapter**: `tandoor.ts` - Recipe management system integration
- **Browser Adapter**: `browser.ts` - Playwright browser automation
### Strategy Pattern
Multiple extraction strategies with fallback:
1. Embedded JSON extraction
2. DOM selector extraction
3. GraphQL API extraction
@@ -150,28 +166,34 @@ Multiple extraction strategies with fallback:
## Key Components
### Queue Management System
**Location**: `src/lib/server/queue/`
Three-phase processing pipeline:
1. **Extraction Phase**: Extract text and thumbnail from Instagram
2. **Parsing Phase**: Parse recipe using LLM
3. **Uploading Phase**: Upload to Tandoor (if enabled)
**Components**:
- `QueueManager`: In-memory FIFO queue with CRUD operations
- `QueueProcessor`: Worker that processes items with configurable concurrency
- `types.ts`: Comprehensive type definitions for queue items and updates
### API Layer
**Location**: `src/routes/api/`
RESTful endpoints for:
- Queue operations (`POST /api/queue`, `GET /api/queue`, `GET /api/queue/[id]`)
- Real-time updates (`GET /api/queue/stream` - SSE)
- Push notifications (`POST /api/notifications/subscribe`)
- Health checks (`GET /api/health`, `GET /api/llm-health`)
### Client-Side Services
**Location**: `src/lib/client/`
- **PushNotificationManager**: Manages Web Push API subscriptions
@@ -179,14 +201,17 @@ RESTful endpoints for:
- **ServiceWorkerMessageHandler**: Processes service worker messages
### Instagram Extraction
**Location**: `src/lib/server/extraction.ts`
Multi-method extraction with intelligent fallback:
- Progress callbacks for real-time feedback
- Retry logic with configurable attempts
- Thumbnail extraction and validation
### LLM Integration
**Location**: `src/lib/server/parser.ts`, `src/lib/server/llm.ts`
- Recipe detection endpoint
@@ -198,6 +223,7 @@ Multi-method extraction with intelligent fallback:
## Dependencies
### Production Dependencies
- **@types/uuid** (^10.0.0) - UUID type definitions
- **date-fns** (^4.1.0) - Date utility library
- **openai** (^4.20.0) - OpenAI API client
@@ -206,6 +232,7 @@ Multi-method extraction with intelligent fallback:
- **zod** (^3.23.0) - Schema validation
### Development Dependencies
- **@sveltejs/kit** (^2.48.5) - SvelteKit framework
- **@sveltejs/adapter-node** (^5.4.0) - Node.js adapter
- **svelte** (^5.43.8) - Svelte 5 framework
@@ -223,12 +250,14 @@ Multi-method extraction with intelligent fallback:
## Module Organization
### SvelteKit Path Aliases
- `$lib``src/lib/`
- `$lib/*``src/lib/*`
- `$app/*` → SvelteKit app imports
- `$env/dynamic/private` → Environment variables (server-side)
### Directory Structure Conventions
- **Server-only code**: `src/lib/server/` (not bundled to client)
- **Client-only code**: `src/lib/client/` (not executed on server)
- **Shared code**: `src/lib/` (available to both)
@@ -240,6 +269,7 @@ Multi-method extraction with intelligent fallback:
## Data Flow
### Recipe Extraction Flow
```
User submits URL
@@ -261,6 +291,7 @@ SSE updates notify client
```
### Real-time Updates Flow
```
Client connects to GET /api/queue/stream (SSE)
@@ -274,6 +305,7 @@ Client updates UI reactively
```
### Push Notification Flow
```
Client requests permission
@@ -295,37 +327,44 @@ Notification displayed to user
## Build System
### Build Command
```bash
npm run build
```
Generates production-ready build in `build/` directory using:
- Vite for bundling
- `@sveltejs/adapter-node` for Node.js deployment
- TypeScript compilation
- SvelteKit prerendering and optimization
### Test Command
```bash
npm test
```
Runs test suite using Vitest with two projects:
1. **Server tests**: Node environment for server-side code
2. **Client tests**: Playwright browser for Svelte components
### Development Server
```bash
npm run dev
```
Starts Vite dev server with:
- HTTPS enabled (certificates in `.ssl/`)
- Hot module replacement
- TypeScript checking
- File watching
### Linting & Formatting
```bash
npm run lint # ESLint + Prettier check
npm run format # Prettier write
@@ -336,19 +375,24 @@ npm run format # Prettier write
## Deployment
### Docker Deployment
Dockerfile includes:
- Node.js 22 Alpine base image
- Playwright Chromium installation
- Production build
- Port 3000 exposure
Run with:
```bash
docker-compose up
```
### Environment Variables
Required configuration:
- `OPENAI_API_KEY` - LLM API access
- `TANDOOR_URL` - Tandoor instance URL (optional)
- `TANDOOR_TOKEN` - Tandoor API token (optional)
@@ -360,13 +404,16 @@ Required configuration:
## Testing Architecture
### Test Categories
1. **Unit Tests**: Individual function testing
2. **Integration Tests**: Multi-component workflows
3. **API Tests**: Endpoint behavior validation
4. **Browser Tests**: Svelte component rendering
### Test Coverage
138 tests covering:
- Queue management operations
- Instagram URL validation
- SSE streaming
@@ -375,6 +422,7 @@ Required configuration:
- Notification service
### Test Configuration
- **Server tests**: Node environment with mocked dependencies
- **Client tests**: Playwright Chromium browser with Svelte testing library
@@ -383,15 +431,18 @@ Required configuration:
## Security Considerations
### SSL/TLS
- Development uses local SSL certificates signed by external Caddy CA
- Certificates stored in `.ssl/` (git-ignored)
- Required for PWA features (Service Worker, Push API)
### Authentication
- Basic auth for scheduled tasks (username/password from environment)
- Tandoor integration uses bearer token authentication
### Input Validation
- Instagram URL validation with regex patterns
- Zod schema validation for API payloads
- Error handling with custom error classes

View File

@@ -19,12 +19,14 @@
### Files & Directories
#### SvelteKit Route Files
- Route pages: `+page.svelte`
- Route servers: `+server.ts`
- Route layouts: `+layout.svelte`
- Type definitions: `$types.ts` (auto-generated)
**Example:**
```
src/routes/api/queue/
├── [id]/
@@ -37,19 +39,23 @@ src/routes/api/queue/
```
#### Library Files
- **PascalCase** for classes and managers: `QueueManager.ts`, `PushNotificationService.ts`
- **kebab-case** for utilities and configs: `tandoor-config.ts`, `instagram-url.ts`
- **lowercase** for general modules: `browser.ts`, `extraction.ts`, `parser.ts`
**Examples from codebase:**
- `src/lib/server/queue/QueueManager.ts`
- `src/lib/server/tandoor-config.ts`
- `src/lib/client/PushNotificationManager.ts`
#### Test Files
Pattern: `<name>.spec.ts` or `<name>.test.ts`
**Examples:**
- `queue-manager.spec.ts`
- `instagram-url-validation.spec.ts`
- `page.svelte.spec.ts`
@@ -57,10 +63,12 @@ Pattern: `<name>.spec.ts` or `<name>.test.ts`
### Variables & Functions
#### Variables
- **camelCase** for local variables and parameters
- **SCREAMING_SNAKE_CASE** for constants
**Examples:**
```typescript
// From QueueManager.ts
private items: Map<string, QueueItem> = new Map();
@@ -76,10 +84,12 @@ const unsubscribe = queueManager.subscribe(callback);
```
#### Functions
- **camelCase** for function names
- **Descriptive action verbs**: `enqueue`, `extractRecipe`, `uploadRecipeImage`
**Examples:**
```typescript
// From QueueManager.ts
enqueue(url: string): QueueItem { ... }
@@ -99,62 +109,62 @@ export async function extractRecipe(text: string): Promise<Recipe> { ... }
### Types & Interfaces
#### Interfaces & Types
- **PascalCase** for interface names
- Prefix with `I` is **NOT** used
- Exported types use `export type` or `export interface`
**Examples:**
```typescript
// From queue/types.ts
export interface QueueItem {
id: string;
url: string;
status: QueueItemStatus;
enqueuedAt: string;
// ...
id: string;
url: string;
status: QueueItemStatus;
enqueuedAt: string;
// ...
}
export interface QueueStatusUpdate {
type: string;
itemId: string;
status: QueueItemStatus;
// ...
type: string;
itemId: string;
status: QueueItemStatus;
// ...
}
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed';
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
// From extraction.ts
export interface ExtractedContent {
text: string;
thumbnailUrl?: string;
text: string;
thumbnailUrl?: string;
}
export type ProgressCallback = (event: ProgressEvent) => void;
```
#### Zod Schemas
- **PascalCase** with `Schema` suffix
- Inferred types without suffix
**Examples:**
```typescript
// From parser.ts
const RecipeSchema = z.object({
name: z.string(),
description: z.string(),
servings: z.number(),
// ...
name: z.string(),
description: z.string(),
servings: z.number()
// ...
});
export type Recipe = z.infer<typeof RecipeSchema>;
// From tandoor.ts
const TandoorRecipeSchema = z.object({
// ...
// ...
});
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
@@ -163,35 +173,38 @@ export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
### Classes
#### Class Names
- **PascalCase** for class names
- Descriptive suffixes: `Manager`, `Service`, `Processor`, `Handler`
**Examples:**
```typescript
// From QueueManager.ts
export class QueueManager {
private items: Map<string, QueueItem> = new Map();
// ...
private items: Map<string, QueueItem> = new Map();
// ...
}
// From QueueProcessor.ts
export class QueueProcessor {
private processing: Set<string> = new Set();
// ...
private processing: Set<string> = new Set();
// ...
}
// From PushNotificationService.ts
class PushNotificationService {
private subscriptions: Map<string, PushSubscription> = new Map();
// ...
private subscriptions: Map<string, PushSubscription> = new Map();
// ...
}
```
#### Singleton Export Pattern
```typescript
// Class definition
export class QueueManager {
// Implementation
// Implementation
}
// Singleton instance export
@@ -203,6 +216,7 @@ export const queueManager = new QueueManager();
## Indentation & Formatting
### General Rules
- **Indentation:** 2 spaces (enforced by Prettier)
- **No tabs**
- **Max line length:** 100 characters (soft limit, not enforced)
@@ -213,6 +227,7 @@ export const queueManager = new QueueManager();
### Code Examples
#### Function Declarations
```typescript
// From QueueManager.ts
enqueue(url: string): QueueItem {
@@ -241,36 +256,38 @@ enqueue(url: string): QueueItem {
```
#### Async Functions
```typescript
// From extraction.ts
export async function extractTextAndThumbnail(
url: string,
onProgress?: ProgressCallback
url: string,
onProgress?: ProgressCallback
): Promise<ExtractedContent> {
const browser = await getBrowser();
const context = await createBrowserContext(browser);
const page = await context.newPage();
const browser = await getBrowser();
const context = await createBrowserContext(browser);
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle' });
// ...
} finally {
await context.close();
}
try {
await page.goto(url, { waitUntil: 'networkidle' });
// ...
} finally {
await context.close();
}
}
```
#### Object Destructuring
```typescript
// From route handlers
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
// ...
const { url } = await request.json();
// ...
};
export const GET: RequestHandler = async ({ params }) => {
const { id } = params;
// ...
const { id } = params;
// ...
};
```
@@ -279,12 +296,14 @@ export const GET: RequestHandler = async ({ params }) => {
## Import Patterns
### Import Order
1. External dependencies (Node.js built-ins, npm packages)
2. SvelteKit imports (`$lib`, `$app`, `$env`)
3. Relative imports (`./ `, `../`)
4. Type imports (separate from value imports when beneficial)
**Example:**
```typescript
// From QueueProcessor.ts
@@ -307,6 +326,7 @@ import type { QueueItem } from './types';
### Import Styles
#### Named Imports (Preferred)
```typescript
import { json } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
@@ -314,12 +334,14 @@ import { validateInstagramUrl } from '$lib/server/validation/instagram-url';
```
#### Type-Only Imports
```typescript
import type { RequestHandler } from './$types';
import type { QueueItem, QueueItemStatus } from './types';
```
#### Default Imports
```typescript
import OpenAI from 'openai';
import fs from 'fs';
@@ -329,6 +351,7 @@ import path from 'path';
### Export Patterns
#### Named Exports (Preferred)
```typescript
// Export functions
export async function extractRecipe(text: string): Promise<Recipe> { ... }
@@ -345,6 +368,7 @@ export interface QueueItem { ... }
```
#### Singleton Pattern Export
```typescript
// Define class
export class QueueManager { ... }
@@ -358,10 +382,12 @@ export const queueManager = new QueueManager();
## Comments & Documentation
### JSDoc Style
Used extensively for public APIs and exported functions.
**Function Documentation:**
```typescript
````typescript
/**
* Add URL to processing queue
*
@@ -377,10 +403,11 @@ Used extensively for public APIs and exported functions.
enqueue(url: string): QueueItem {
// Implementation
}
```
````
**Class Documentation:**
```typescript
````typescript
/**
* Singleton queue manager for processing Instagram URLs
*
@@ -400,11 +427,12 @@ enqueue(url: string): QueueItem {
* ```
*/
export class QueueManager {
// Implementation
// Implementation
}
```
````
**Module-Level Documentation:**
```typescript
/**
* Queue Manager - Core queue operations and event management
@@ -421,19 +449,21 @@ export class QueueManager {
### Inline Comments
#### Single-line Comments
```typescript
// Set restrictive permissions
fs.chmodSync(authFile, 0o600);
// FIFO order - get oldest pending item
const pendingItems = Array.from(this.items.values())
.filter(item => item.status === 'pending');
const pendingItems = Array.from(this.items.values()).filter((item) => item.status === 'pending');
```
#### Block Comments (Avoided)
Single-line comments preferred. Block comments used only for large comment blocks or temporarily disabling code during development.
### TODO Comments
```typescript
// TODO: Add retry logic with exponential backoff
// FIXME: Handle race condition when multiple workers dequeue
@@ -446,17 +476,19 @@ Single-line comments preferred. Block comments used only for large comment block
### Type Safety
#### Strict Mode Enabled
```json
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
}
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
}
}
```
#### Type Annotations
```typescript
// Explicit return types for public functions
export async function extractRecipe(text: string): Promise<Recipe> { ... }
@@ -469,35 +501,24 @@ const items = queueManager.getAll(); // Type inferred
```
### Union Types
```typescript
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed';
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export type ProcessingPhase =
| 'extraction'
| 'parsing'
| 'uploading';
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
export type ProgressEventType =
| 'status'
| 'method'
| 'retry'
| 'error'
| 'thumbnail'
| 'complete';
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
```
### Generics
```typescript
// Generic function
async function fetchFromTandoor<T>(
url: string,
options: Partial<RequestInit> = { method: 'GET' }
url: string,
options: Partial<RequestInit> = { method: 'GET' }
): Promise<{ ok: boolean; data?: T; error?: string }> {
// Implementation
// Implementation
}
```
@@ -508,6 +529,7 @@ async function fetchFromTandoor<T>(
### Runes (Reactivity)
#### $state (Reactive Variables)
```svelte
<script lang="ts">
let count = $state(0);
@@ -516,6 +538,7 @@ async function fetchFromTandoor<T>(
```
#### $props (Component Props)
```svelte
<script lang="ts">
let {
@@ -533,6 +556,7 @@ async function fetchFromTandoor<T>(
```
#### $derived (Computed Values)
```svelte
<script lang="ts">
let count = $state(0);
@@ -541,6 +565,7 @@ async function fetchFromTandoor<T>(
```
#### $effect (Side Effects)
```svelte
<script lang="ts">
let url = $state('');
@@ -552,6 +577,7 @@ async function fetchFromTandoor<T>(
```
### Component Structure
```svelte
<script lang="ts">
// Imports
@@ -593,46 +619,47 @@ async function fetchFromTandoor<T>(
## Error Handling
### Custom Error Classes
```typescript
// From api/errors.ts
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
export class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}
```
### Try-Catch Pattern
```typescript
export const POST: RequestHandler = async ({ request }) => {
try {
const { url } = await request.json();
try {
const { url } = await request.json();
if (!url) {
throw new ValidationError('URL is required');
}
if (!url) {
throw new ValidationError('URL is required');
}
const item = queueManager.enqueue(url);
return json(item, { status: 201 });
} catch (error) {
return handleApiError(error);
}
const item = queueManager.enqueue(url);
return json(item, { status: 201 });
} catch (error) {
return handleApiError(error);
}
};
```
@@ -641,6 +668,7 @@ export const POST: RequestHandler = async ({ request }) => {
## Linting Configuration
### ESLint
**Config:** `eslint.config.js`
- Base: `@eslint/js` recommended
@@ -649,6 +677,7 @@ export const POST: RequestHandler = async ({ request }) => {
- Formatting: `eslint-config-prettier`
**Rules:**
```javascript
{
rules: {
@@ -658,15 +687,16 @@ export const POST: RequestHandler = async ({ request }) => {
```
### Prettier
**Config:** `.prettierrc`
```json
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
}
```
@@ -675,38 +705,40 @@ export const POST: RequestHandler = async ({ request }) => {
## Testing Conventions
### Test Structure
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('QueueManager', () => {
let manager: QueueManager;
let manager: QueueManager;
beforeEach(() => {
manager = new QueueManager();
});
beforeEach(() => {
manager = new QueueManager();
});
it('should enqueue items', () => {
const item = manager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
});
it('should enqueue items', () => {
const item = manager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
});
it('should dequeue items in FIFO order', () => {
manager.enqueue('url1');
manager.enqueue('url2');
it('should dequeue items in FIFO order', () => {
manager.enqueue('url1');
manager.enqueue('url2');
const first = manager.dequeue();
expect(first?.url).toBe('url1');
});
const first = manager.dequeue();
expect(first?.url).toBe('url1');
});
});
```
### Mock Pattern
```typescript
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
text: 'Mock text',
thumbnailUrl: 'https://example.com/thumb.jpg'
})
extractTextAndThumbnail: vi.fn().mockResolvedValue({
text: 'Mock text',
thumbnailUrl: 'https://example.com/thumb.jpg'
})
}));
```
@@ -715,6 +747,7 @@ vi.mock('$lib/server/extraction', () => ({
## File Headers
### Module Documentation Pattern
Every major module includes a header comment:
```typescript
@@ -730,6 +763,7 @@ Every major module includes a header comment:
```
**Example:**
```typescript
/**
* Queue Manager - Core queue operations and event management
@@ -748,6 +782,7 @@ Every major module includes a header comment:
## Additional Conventions
### Environment Variables
```typescript
import { env } from '$env/dynamic/private';
@@ -756,32 +791,37 @@ const tandoorUrl = env.TANDOOR_URL || null;
```
### Date Handling
ISO8601 strings throughout the application:
```typescript
const now = new Date().toISOString();
// Output: "2026-02-15T12:30:45.123Z"
```
### Null vs Undefined
- `null`: Intentional absence of value
- `undefined`: Not yet initialized or optional parameters
- Prefer `null` for API responses and data structures
### Async/Await
Always preferred over Promise chains:
```typescript
// Preferred
async function fetchData() {
const response = await fetch(url);
const data = await response.json();
return data;
const response = await fetch(url);
const data = await response.json();
return data;
}
// Avoid
function fetchData() {
return fetch(url)
.then(response => response.json())
.then(data => data);
return fetch(url)
.then((response) => response.json())
.then((data) => data);
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ The migration transformed InstaRecipe from a blocking, synchronous extraction sy
### Architecture Transformation
**Before: Synchronous System**
```
User Request → Direct Processing → Response (wait 30-60s)
↓ ↓ ↓
@@ -18,6 +19,7 @@ User Request → Direct Processing → Response (wait 30-60s)
```
**After: Async Queue System**
```
User Request → Queue Item Created → Immediate Response
↓ ↓ ↓
@@ -60,6 +62,7 @@ User Request → Queue Item Created → Immediate Response
### New Endpoints
#### Queue Management
```typescript
// Enqueue URL for processing
POST /api/queue
@@ -84,6 +87,7 @@ Events: connection, queue-update, ping
```
#### Push Notifications
```typescript
// Subscribe to push notifications
POST /api/notifications/subscribe
@@ -101,11 +105,11 @@ These endpoints are marked for removal and should not be used in new code:
```typescript
// ❌ DEPRECATED: Synchronous extraction
POST /api/extract
POST / api / extract;
// 👉 Use: POST /api/queue
// ❌ DEPRECATED: Long-polling progress
GET /api/extract-stream
GET / api / extract - stream;
// 👉 Use: GET /api/queue/stream
```
@@ -117,33 +121,33 @@ New queue items follow this structure:
```typescript
interface QueueItem {
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
// Processing phases with individual progress
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string;
completedAt?: string;
progress?: number; // 0-100
}>;
// Processing phases with individual progress
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string;
completedAt?: string;
progress?: number; // 0-100
}>;
// Results (populated on success)
results?: {
recipe?: Recipe; // Extracted recipe data
tandoorUrl?: string; // Link to uploaded recipe
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
// Results (populated on success)
results?: {
recipe?: Recipe; // Extracted recipe data
tandoorUrl?: string; // Link to uploaded recipe
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
// Error information
error?: string;
// Error information
error?: string;
// Timestamps
createdAt: string;
updatedAt: string;
// Timestamps
createdAt: string;
updatedAt: string;
}
```
@@ -167,18 +171,19 @@ interface QueueStatusUpdate {
### For Frontend Applications
1. **Replace Synchronous Calls**
```typescript
// ❌ Old synchronous approach
const response = await fetch('/api/extract', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const result = await response.json(); // Wait 30-60 seconds
// ✅ New async queue approach
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const queueItem = await response.json(); // Immediate response
@@ -187,13 +192,14 @@ interface QueueStatusUpdate {
```
2. **Add Real-time Updates**
```typescript
// Setup Server-Sent Events for progress tracking
const eventSource = new EventSource(`/api/queue/stream?itemId=${itemId}`);
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
updateUI(update);
const update = JSON.parse(event.data);
updateUI(update);
});
```
@@ -201,27 +207,28 @@ interface QueueStatusUpdate {
```typescript
// Handle different queue statuses
switch (item.status) {
case 'pending':
showPendingState();
break;
case 'in_progress':
showProgressBar(item.phases);
break;
case 'success':
showResults(item.results);
break;
case 'error':
showErrorWithRetry(item.error, item.id);
break;
case 'unhealthy':
showRetryableError(item.error, item.id);
break;
case 'pending':
showPendingState();
break;
case 'in_progress':
showProgressBar(item.phases);
break;
case 'success':
showResults(item.results);
break;
case 'error':
showErrorWithRetry(item.error, item.id);
break;
case 'unhealthy':
showRetryableError(item.error, item.id);
break;
}
```
### For Backend Integrations
1. **Update API Calls**
```python
# ❌ Old synchronous API
response = requests.post('/api/extract', json={'url': url})
@@ -240,6 +247,7 @@ interface QueueStatusUpdate {
```
2. **Implement SSE Client** (Python example)
```python
import sseclient
@@ -314,18 +322,21 @@ npm test queue-sse
## Performance Considerations
### Before Migration
- **Blocking Operations**: Each request blocked a server thread
- **Single Processing**: One extraction at a time
- **No Progress**: Users waited without feedback
- **Memory Usage**: High memory usage during long operations
### After Migration
- **Non-blocking**: Requests return immediately
- **Concurrent Processing**: Multiple extractions in parallel
- **Real-time Feedback**: Live progress updates
- **Efficient Memory**: Event-driven, minimal memory footprint
### Performance Metrics
- **Response Time**: 50ms (queue) vs 30-60s (synchronous)
- **Throughput**: 2x concurrent processing vs 1x sequential
- **User Experience**: Immediate feedback vs long waiting
@@ -336,11 +347,13 @@ npm test queue-sse
If issues arise, the system can be rolled back by:
1. **Disable Queue Processing**
```env
QUEUE_PROCESSING_ENABLED=false
```
2. **Re-enable Legacy Endpoints** (if preserved)
```typescript
// Temporary fallback to synchronous processing
app.post('/api/extract', legacyExtractHandler);

View File

@@ -3,6 +3,7 @@
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
## Table of Contents
- [Core Principle](#core-principle)
- [Browser API Detection](#browser-api-detection)
- [Lifecycle Hooks](#lifecycle-hooks)
@@ -18,6 +19,7 @@ This guide documents SSR-safe patterns and anti-patterns for InstaRecipe develop
**SvelteKit renders components on both the server and client.** Any browser-only APIs must be guarded or used in browser-only contexts.
### Browser-Only APIs (Require Guards)
- `window.*`
- `document.*`
- `localStorage`, `sessionStorage`
@@ -36,8 +38,8 @@ This guide documents SSR-safe patterns and anti-patterns for InstaRecipe develop
import { browser } from '$app/environment';
if (browser) {
// Safe: only runs in browser
const data = localStorage.getItem('key');
// Safe: only runs in browser
const data = localStorage.getItem('key');
}
```
@@ -72,6 +74,7 @@ if (browser) {
### `onMount` - Browser-Only Lifecycle
**Use `onMount` for:**
- Browser API initialization
- Timer setup (`setInterval`, `setTimeout`)
- Event listener registration
@@ -81,12 +84,12 @@ if (browser) {
import { onMount } from 'svelte';
onMount(() => {
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
return () => clearInterval(interval); // Cleanup
return () => clearInterval(interval); // Cleanup
});
```
@@ -96,8 +99,8 @@ onMount(() => {
import { onDestroy } from 'svelte';
onDestroy(() => {
// ✅ Safe for cleanup
eventSource?.close();
// ✅ Safe for cleanup
eventSource?.close();
});
```
@@ -117,7 +120,7 @@ let stored = $state(localStorage.getItem('key')); // SSR crash!
// ✅ DO: Load in onMount
let stored = $state<string | null>(null);
onMount(() => {
stored = localStorage.getItem('key');
stored = localStorage.getItem('key');
});
```
@@ -142,29 +145,31 @@ let userAgent = $derived(navigator.userAgent); // SSR crash!
```typescript
// ❌ BAD: No browser guard
$effect(() => {
setInterval(() => checkHealth(), 1000); // SSR crash!
setInterval(() => checkHealth(), 1000); // SSR crash!
});
// ✅ GOOD: With browser guard
$effect(() => {
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
// ✅ BETTER: Use onMount for initialization instead
onMount(() => {
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
```
**When to use `$effect`:**
- Synchronizing derived state
- DOM manipulation (with browser guard)
- Reactive cleanup
**When NOT to use `$effect`:**
- Initialization (use `onMount`)
- API calls on mount (use `onMount`)
- Timer setup (use `onMount`)
@@ -189,11 +194,13 @@ if (browser && eventSource?.readyState === EventSource.OPEN)
```
**EventSource States:**
- `EventSource.CONNECTING = 0`
- `EventSource.OPEN = 1`
- `EventSource.CLOSED = 2`
**WebSocket States:**
- `WebSocket.CONNECTING = 0`
- `WebSocket.OPEN = 1`
- `WebSocket.CLOSING = 2`
@@ -220,8 +227,8 @@ const interval = setInterval(() => {}, 1000); // SSR crash!
// ✅ GOOD: In onMount
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
@@ -260,22 +267,23 @@ onMount(() => {
import { browser } from '$app/environment';
export class PushNotificationManager {
private static instance: PushNotificationManager | null = null;
private static instance: PushNotificationManager | null = null;
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
}
```
**Why it's good:**
- Guards all browser API access
- Early returns prevent unnecessary code execution during SSR
- Defensive programming with null checks
@@ -327,6 +335,7 @@ export class PushNotificationManager {
```
**Why it's good:**
- Uses `onMount` instead of `$effect` for initialization
- Timer setup in browser-only context
- Proper cleanup with return function
@@ -344,7 +353,7 @@ let theme = $derived(localStorage.getItem('theme'));
// ✅ DO
let theme = $state<string | null>(null);
onMount(() => {
theme = localStorage.getItem('theme');
theme = localStorage.getItem('theme');
});
```
@@ -353,19 +362,19 @@ onMount(() => {
```typescript
// ❌ DON'T
$effect(() => {
// Runs during SSR!
fetch('/api/data');
// Runs during SSR!
fetch('/api/data');
});
// ✅ DO: Guard browser-specific side effects
$effect(() => {
if (!browser) return;
fetch('/api/data');
if (!browser) return;
fetch('/api/data');
});
// ✅ BETTER: Use onMount for initialization
onMount(() => {
fetch('/api/data');
fetch('/api/data');
});
```
@@ -387,8 +396,8 @@ const interval = setInterval(() => {}, 1000);
// ✅ DO
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
@@ -408,6 +417,7 @@ Watch server console for errors during build and preview.
### 2. Check for Hydration Warnings
Open browser DevTools console and look for:
- "Hydration failed"
- "The server response doesn't match the client content"
@@ -422,6 +432,7 @@ grep -r "navigator\." src/routes --include="*.svelte"
```
Then verify each usage is either:
- In an event handler (safe)
- In `onMount` (safe)
- Guarded with `if (browser)` (safe)

View File

@@ -32,12 +32,12 @@ SvelteKit has a unique architecture where code can run on both server and client
import { env } from '$env/dynamic/private';
export const queueConfig = {
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
token: env.TANDOOR_TOKEN || null
}
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
token: env.TANDOOR_TOKEN || null
}
};
```
@@ -49,21 +49,21 @@ import * as queueConfigModule from '$lib/server/queue/config';
// Mock the config module
vi.mock('$lib/server/queue/config', () => ({
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: { enabled: true, token: 'test-token' }
}
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: { enabled: true, token: 'test-token' }
}
}));
describe('QueueProcessor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
});
```
@@ -78,10 +78,10 @@ import { vi } from 'vitest';
// IMPORTANT: Mock BEFORE importing the module that uses it
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Mock recipe text',
thumbnail: 'https://mock.com/image.jpg'
})
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Mock recipe text',
thumbnail: 'https://mock.com/image.jpg'
})
}));
// NOW import the module that depends on these
@@ -89,15 +89,15 @@ import { queueProcessor } from '$lib/server/queue/QueueProcessor';
import { extractTextAndThumbnail } from '$lib/server/extraction';
describe('QueueProcessor', () => {
it('should use mocked services', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
it('should use mocked services', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
// Verify mock was called
expect(extractTextAndThumbnail).toHaveBeenCalledWith(
'https://instagram.com/p/test',
expect.any(Function)
);
});
// Verify mock was called
expect(extractTextAndThumbnail).toHaveBeenCalledWith(
'https://instagram.com/p/test',
expect.any(Function)
);
});
});
```
@@ -112,22 +112,22 @@ import { describe, it, expect } from 'vitest';
import { POST } from '../routes/api/queue/+server';
describe('POST /api/queue', () => {
it('should reject invalid URLs', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'invalid-url' })
});
it('should reject invalid URLs', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'invalid-url' })
});
const response = await POST({ request } as any);
const response = await POST({ request } as any);
// ✅ CORRECT - Check status first
expect(response.status).toBe(400);
// ✅ CORRECT - Check status first
expect(response.status).toBe(400);
// ✅ CORRECT - Properly await error response
const data = await response.json();
expect(data.message).toContain('Invalid');
});
// ✅ CORRECT - Properly await error response
const data = await response.json();
expect(data.message).toContain('Invalid');
});
});
```
@@ -136,17 +136,17 @@ describe('POST /api/queue', () => {
```typescript
// ❌ WRONG - This will fail
it('should reject invalid input', async () => {
const response = await endpoint({ request } as any);
const data = response.json(); // Missing await!
expect(data.message).toBe('Error'); // data is a Promise
const response = await endpoint({ request } as any);
const data = response.json(); // Missing await!
expect(data.message).toBe('Error'); // data is a Promise
});
// ✅ CORRECT
it('should reject invalid input', async () => {
const response = await endpoint({ request } as any);
expect(response.status).toBe(400);
const data = await response.json(); // Properly awaited
expect(data.message).toBe('Error');
const response = await endpoint({ request } as any);
expect(response.status).toBe(400);
const data = await response.json(); // Properly awaited
expect(data.message).toBe('Error');
});
```
@@ -173,11 +173,11 @@ import { queueProcessor } from './QueueProcessor';
import { beforeEach, afterEach } from 'vitest';
beforeEach(() => {
vi.clearAllMocks(); // Clear call history
vi.clearAllMocks(); // Clear call history
});
afterEach(() => {
vi.restoreAllMocks(); // Restore original implementations
vi.restoreAllMocks(); // Restore original implementations
});
```
@@ -203,16 +203,16 @@ const mockFn = vi.fn() as Mock<() => Promise<string>>;
```typescript
it('should process item', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const item = queueManager.enqueue('https://instagram.com/p/test');
// Wait for processing with timeout
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('success');
},
{ timeout: 5000, interval: 100 }
);
// Wait for processing with timeout
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('success');
},
{ timeout: 5000, interval: 100 }
);
});
```
@@ -222,20 +222,20 @@ it('should process item', async () => {
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.useRealTimers();
});
it('should process after delay', async () => {
queueManager.enqueue('https://test.com');
queueManager.enqueue('https://test.com');
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000);
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000);
// Now check results
// Now check results
});
```
@@ -263,7 +263,7 @@ vi.mock('./module', () => ({ export: vi.fn() }));
// Mock with factory
vi.mock('./module', () => {
return { dynamicExport: () => 'value' };
return { dynamicExport: () => 'value' };
});
// Spy on existing export
@@ -285,9 +285,9 @@ expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg');
// Reset/restore
vi.clearAllMocks(); // Clear call history
vi.resetAllMocks(); // + Reset implementations
vi.restoreAllMocks(); // + Restore original implementations
vi.clearAllMocks(); // Clear call history
vi.resetAllMocks(); // + Reset implementations
vi.restoreAllMocks(); // + Restore original implementations
// Environment variables
vi.stubEnv('VAR_NAME', 'value');

File diff suppressed because it is too large Load Diff

View File

@@ -21,16 +21,14 @@ export default defineConfig(
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": 'off' }
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: [
'**/*.svelte',
'**/*.svelte.ts',
'**/*.svelte.js'
],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,

View File

@@ -1,63 +1,63 @@
{
"name": "insta-recipe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:host": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"fast-glob": "^3.3.3",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^6.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"date-fns": "^4.1.0",
"openai": "^4.20.0",
"playwright": "^1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^3.23.0"
},
"overrides": {
"cookie": "^0.7.0",
"ajv": "^8.18.0"
}
"name": "insta-recipe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:host": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"fast-glob": "^3.3.3",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^6.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"date-fns": "^4.1.0",
"openai": "^4.20.0",
"playwright": "^1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^3.23.0"
},
"overrides": {
"cookie": "^0.7.0",
"ajv": "^8.18.0"
}
}

View File

@@ -6,29 +6,29 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testMatch: '**/*.e2e.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
testDir: './src/tests',
testMatch: '**/*.e2e.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});

View File

@@ -4,23 +4,23 @@ import fs from 'fs';
import path from 'path';
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
console.log('🔹 Navigating to Instagram...');
await page.goto('https://www.instagram.com/');
console.log('⏳ Please log in manually. Waiting for "Home" icon...');
console.log('🔹 Navigating to Instagram...');
await page.goto('https://www.instagram.com/');
console.log('⏳ Please log in manually. Waiting for "Home" icon...');
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('../secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('../secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
await context.storageState({ path: path.join(secretsDir, 'auth.json') });
console.log('🎉 Session saved to secrets/auth.json');
} catch (e) {
console.error('❌ Timeout or error:', e);
}
await browser.close();
await context.storageState({ path: path.join(secretsDir, 'auth.json') });
console.log('🎉 Session saved to secrets/auth.json');
} catch (e) {
console.error('❌ Timeout or error:', e);
}
await browser.close();
})();

View File

@@ -7,50 +7,50 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateFaviconIco() {
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.ico');
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.ico');
console.log('Generating favicon.ico from icon-source.png...');
console.log('Generating favicon.ico from icon-source.png...');
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Resize to 32x32 with transparent background
await sharp(sourceIcon)
.resize(32, 32, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Resize to 32x32 with transparent background
await sharp(sourceIcon)
.resize(32, 32, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
console.log(`✓ favicon.ico generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
console.log(`✓ favicon.ico generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
// Validate success criteria
if (metadata.width !== 32 || metadata.height !== 32) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
// Validate success criteria
if (metadata.width !== 32 || metadata.height !== 32) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
console.log('✓ All validation checks passed');
console.log('✓ All validation checks passed');
}
generateFaviconIco().catch(err => {
console.error('Error generating favicon.ico:', err);
process.exit(1);
generateFaviconIco().catch((err) => {
console.error('Error generating favicon.ico:', err);
process.exit(1);
});

View File

@@ -7,54 +7,54 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateFavicon() {
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.png');
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.png');
console.log('Generating favicon.png from icon-source.png...');
console.log('Generating favicon.png from icon-source.png...');
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Resize to 192x192 with transparent background
await sharp(sourceIcon)
.resize(192, 192, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Resize to 192x192 with transparent background
await sharp(sourceIcon)
.resize(192, 192, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
console.log(`✓ favicon.png generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
console.log(`✓ favicon.png generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
// Validate success criteria
if (metadata.width !== 192 || metadata.height !== 192) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 100 * 1024) {
console.error('Error: File size exceeds 100KB');
process.exit(1);
}
// Validate success criteria
if (metadata.width !== 192 || metadata.height !== 192) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 100 * 1024) {
console.error('Error: File size exceeds 100KB');
process.exit(1);
}
console.log('✓ All validation checks passed');
console.log('✓ All validation checks passed');
}
generateFavicon().catch(err => {
console.error('Error generating favicon:', err);
process.exit(1);
generateFavicon().catch((err) => {
console.error('Error generating favicon:', err);
process.exit(1);
});

View File

@@ -2,54 +2,54 @@ const sharp = require('sharp');
const fs = require('fs');
async function generateIcon512() {
try {
console.log('Generating icon-512.png from icon-source.png...');
try {
console.log('Generating icon-512.png from icon-source.png...');
// Check if source file exists
if (!fs.existsSync('static/icon-source.png')) {
console.error('Error: static/icon-source.png does not exist');
process.exit(1);
}
// Check if source file exists
if (!fs.existsSync('static/icon-source.png')) {
console.error('Error: static/icon-source.png does not exist');
process.exit(1);
}
// Generate 512x512 icon
await sharp('static/icon-source.png')
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile('static/icon-512.png');
// Generate 512x512 icon
await sharp('static/icon-source.png')
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile('static/icon-512.png');
console.log('✓ Generated static/icon-512.png');
console.log('✓ Generated static/icon-512.png');
// Verify the result
const metadata = await sharp('static/icon-512.png').metadata();
const stats = fs.statSync('static/icon-512.png');
// Verify the result
const metadata = await sharp('static/icon-512.png').metadata();
const stats = fs.statSync('static/icon-512.png');
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${Math.round(stats.size / 1024)}KB`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${Math.round(stats.size / 1024)}KB`);
// Validate
if (metadata.width !== 512 || metadata.height !== 512) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 200 * 1024) {
console.error('Error: File size exceeds 200KB');
process.exit(1);
}
// Validate
if (metadata.width !== 512 || metadata.height !== 512) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 200 * 1024) {
console.error('Error: File size exceeds 200KB');
process.exit(1);
}
console.log('✓ Validation passed');
process.exit(0);
} catch (error) {
console.error('Error generating icon:', error.message);
process.exit(1);
}
console.log('✓ Validation passed');
process.exit(0);
} catch (error) {
console.error('Error generating icon:', error.message);
process.exit(1);
}
}
generateIcon512();

View File

@@ -1,135 +1,135 @@
{
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805933297.800746,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525143,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
"domain": ".instagram.com",
"path": "/",
"expires": 1796208680.653147,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525191,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1779149297.800838,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1797910987.674116,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "wd",
"value": "1280x720",
"domain": ".instagram.com",
"path": "/",
"expires": 1771978099,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802909297:01fe3b61178e8a40cb655e8d1aa137841487d29a8caf122f877ce2955e92fb4307b53ced\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "9b4abdd3-f38f-473e-9e3b-2ae722acc64d"
},
{
"name": "hb_timestamp",
"value": "1771370599886"
},
{
"name": "IGSession",
"value": "k75336:1771375099770"
},
{
"name": "mutex_polaris_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771371499888"
},
{
"name": "Session",
"value": "t5cu8b:1771373334770"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "mutex_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805933297.800746,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525143,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
"domain": ".instagram.com",
"path": "/",
"expires": 1796208680.653147,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525191,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1779149297.800838,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1797910987.674116,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "wd",
"value": "1280x720",
"domain": ".instagram.com",
"path": "/",
"expires": 1771978099,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802909297:01fe3b61178e8a40cb655e8d1aa137841487d29a8caf122f877ce2955e92fb4307b53ced\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "9b4abdd3-f38f-473e-9e3b-2ae722acc64d"
},
{
"name": "hb_timestamp",
"value": "1771370599886"
},
{
"name": "IGSession",
"value": "k75336:1771375099770"
},
{
"name": "mutex_polaris_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771371499888"
},
{
"name": "Session",
"value": "t5cu8b:1771373334770"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "mutex_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="/manifest.json" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -9,192 +9,192 @@
import { browser } from '$app/environment';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export class PWAInstallManager {
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Clear dismissal state since user installed
this.clearDismissed();
});
// Clear dismissal state since user installed
this.clearDismissed();
});
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
// Call immediately with current state
callback(this.canInstall());
// Call immediately with current state
callback(this.canInstall());
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach(callback => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach((callback) => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
const userAgent = navigator.userAgent.toLowerCase();
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
return 'unknown';
}
}
// Singleton instance for application-wide use

View File

@@ -10,367 +10,359 @@
import { browser } from '$app/environment';
interface NotificationState {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
}
class PushNotificationManager {
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
this.listeners.push(callback);
callback(this.state); // Send initial state
this.listeners.push(callback);
callback(this.state); // Send initial state
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
this.state.supported = (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
this.state.supported =
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
if (this.state.permission === 'granted') {
return true;
}
if (this.state.permission === 'granted') {
return true;
}
try {
this.state.loading = true;
this.notifyListeners();
try {
this.state.loading = true;
this.notifyListeners();
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
this.state.loading = false;
this.notifyListeners();
this.state.loading = false;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!await this.requestPermission()) {
return false;
}
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!(await this.requestPermission())) {
return false;
}
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
const { publicKey } = await vapidResponse.json();
const { publicKey } = await vapidResponse.json();
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
try {
// Add proper padding
const padding = '='.repeat((4 - (cleanKey.length % 4)) % 4);
const base64 = (cleanKey + padding).replace(/-/g, '+').replace(/_/g, '/');
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
try {
// Add proper padding
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
const base64 = (cleanKey + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach(callback => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach((callback) => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
}
// Singleton instance

View File

@@ -5,196 +5,196 @@
* and coordinates with the main application.
*/
import { pushState } from "$app/navigation";
import { pushState } from '$app/navigation';
interface ServiceWorkerMessage {
type: string;
action?: string;
data?: any;
type: string;
action?: string;
data?: any;
}
class ServiceWorkerMessageHandler {
private retryCallbacks = new Map<string, () => void>();
private retryCallbacks = new Map<string, () => void>();
constructor() {
this.initializeMessageListener();
}
constructor() {
this.initializeMessageListener();
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
case 'retry':
this.handleRetryAction(data.itemId);
break;
case 'retry':
this.handleRetryAction(data.itemId);
break;
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
// Refresh page to show the item
//window.location.reload();
}
}
// Refresh page to show the item
//window.location.reload();
}
}
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
registration.active?.postMessage(message, [channel.port2]);
registration.active?.postMessage(message, [channel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
}
// Singleton instance

View File

@@ -24,41 +24,51 @@ import { logError } from '../utils/logger';
* @returns JSON response with appropriate status code and error message
*/
export function handleApiError(error: unknown): Response {
// Log all errors for debugging
logError('[API Error]', error);
// Log all errors for debugging
logError('[API Error]', error);
// Handle known error types with specific status codes
if (error instanceof ValidationError) {
return json({
message: error.message,
type: 'validation_error'
}, { status: 400 });
}
// Handle known error types with specific status codes
if (error instanceof ValidationError) {
return json(
{
message: error.message,
type: 'validation_error'
},
{ status: 400 }
);
}
if (error instanceof NotFoundError) {
return json({
message: error.message,
type: 'not_found_error'
}, { status: 404 });
}
if (error instanceof NotFoundError) {
return json(
{
message: error.message,
type: 'not_found_error'
},
{ status: 404 }
);
}
if (error instanceof ConflictError) {
return json({
message: error.message,
type: 'conflict_error'
}, { status: 409 });
}
if (error instanceof ConflictError) {
return json(
{
message: error.message,
type: 'conflict_error'
},
{ status: 409 }
);
}
// Handle generic errors
const message = error instanceof Error ? error.message : 'Unknown error occurred';
// Handle generic errors
const message = error instanceof Error ? error.message : 'Unknown error occurred';
// Don't expose internal error details in production
const publicMessage = process.env.NODE_ENV === 'production'
? 'Internal server error'
: message;
// Don't expose internal error details in production
const publicMessage = process.env.NODE_ENV === 'production' ? 'Internal server error' : message;
return json({
message: publicMessage,
type: 'server_error'
}, { status: 500 });
return json(
{
message: publicMessage,
type: 'server_error'
},
{ status: 500 }
);
}

View File

@@ -15,10 +15,10 @@
* Thrown when request data is invalid or malformed
*/
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
/**
@@ -26,10 +26,10 @@ export class ValidationError extends Error {
* Thrown when requested resource does not exist
*/
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
/**
@@ -37,8 +37,8 @@ export class NotFoundError extends Error {
* Thrown when operation conflicts with current resource state
*/
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}

View File

@@ -9,7 +9,14 @@ export interface ExtractedContent {
thumbnail: string | null;
}
export type ExtractionMethod = 'embedded-json' | 'internal-state' | 'html-section' | 'dom-selector' | 'graphql-api' | 'graphql-intercept' | 'legacy';
export type ExtractionMethod =
| 'embedded-json'
| 'internal-state'
| 'html-section'
| 'dom-selector'
| 'graphql-api'
| 'graphql-intercept'
| 'legacy';
type CaptionCandidate = {
element: Element;
@@ -192,7 +199,146 @@ function extractShortcode(url: string): string | undefined {
}
/**
* Clean extracted text
* Recipe keywords used for caption scoring
*/
const RECIPE_KEYWORDS = [
'ingredienti',
'procedimento',
'preparazione',
'ricetta',
'recipe',
'instructions'
];
/**
* Timeout configuration constants (in milliseconds)
*/
const TIMEOUTS = {
CONTENT_LOAD: 1500,
MORE_BUTTON_VISIBILITY: 1000,
CAPTION_EXPANSION: 3000,
MORE_BUTTON_VISIBILITY_DOM: 500,
MORE_BUTTON_CLICK: 800,
PAGE_LOAD: 10000,
NETWORK_SETTLE: 2000,
ARTICLE_SELECTOR: 5000,
GRAPHQL_WAIT: 1000,
PAGE_NAVIGATION: 30000,
ANTI_DETECTION_MIN: 1000,
ANTI_DETECTION_MAX: 3000
} as const;
/**
* Try to expand truncated caption by clicking "more" button in HTML section method
*/
async function tryExpandCaptionInHTMLSection(page: Page): Promise<void> {
console.log('[Extractor] Looking for "more" button in primary post container...');
try {
await page.waitForTimeout(TIMEOUTS.CONTENT_LOAD);
const mainContainer = page.locator('article, main, [role="main"]').first();
const containerExists = (await mainContainer.count()) > 0;
if (!containerExists) {
console.log('[Extractor] No main container found');
return;
}
console.log('[Extractor] Found main post container, searching for "more" button...');
const morePatterns = [
{
locator: mainContainer.locator('span').filter({ hasText: /\.\.\.\s*more/i }),
desc: "span with '...more'"
},
{
locator: mainContainer.locator('span').filter({ hasText: /…\s*more/i }),
desc: "span with '… more'"
},
{
locator: mainContainer.locator('div[role="button"]').filter({ hasText: /more/i }),
desc: "button with 'more'"
},
{
locator: mainContainer.locator('span[role="button"]').filter({ hasText: /more/i }),
desc: "span button with 'more'"
}
];
for (const pattern of morePatterns) {
const count = await pattern.locator.count();
console.log(`[Extractor] Checking ${pattern.desc}: found ${count}`);
if (count === 0) continue;
const firstMore = pattern.locator.first();
try {
if (await firstMore.isVisible({ timeout: TIMEOUTS.MORE_BUTTON_VISIBILITY })) {
const text = await firstMore.textContent();
console.log(`[Extractor] Found visible "more": "${text}"`);
await firstMore.click();
console.log('[Extractor] Clicked "more" - waiting for expansion...');
await page.waitForTimeout(TIMEOUTS.CAPTION_EXPANSION);
console.log('[Extractor] Caption expansion complete');
break;
}
} catch (e) {
console.log(`[Extractor] ${pattern.desc} not clickable: ${e}`);
}
}
console.log('[Extractor] Finished "more" button expansion attempt');
} catch (e) {
console.log(`[Extractor] Error while trying to expand caption: ${e}`);
}
}
/**
* Try to expand truncated caption by clicking "more" button in DOM method
*/
async function tryExpandCaptionInDOM(page: Page): Promise<void> {
const moreButtonSelectors = [
'article button:has-text("more")',
'article button:has-text("More")',
'article button:has-text("… more")',
'article span[role="button"]:has-text("more")',
'article [role="button"]:has-text("more")',
'article div[role="button"]:has-text("more")',
'xpath=//article//span[contains(text(), "more")]/..',
'xpath=//article//button[contains(., "more")]'
];
const maxExpandAttempts = 3;
let expandAttempts = 0;
while (expandAttempts < maxExpandAttempts) {
try {
let clicked = false;
for (const selector of moreButtonSelectors) {
try {
const button = page.locator(selector).first();
if (await button.isVisible({ timeout: TIMEOUTS.MORE_BUTTON_VISIBILITY_DOM })) {
await button.click();
await page.waitForTimeout(TIMEOUTS.MORE_BUTTON_CLICK);
console.log(`[Extractor] Clicked "more" button with selector: ${selector}`);
clicked = true;
expandAttempts++;
break;
}
} catch (e) {
// Try next selector
}
}
if (!clicked) break;
} catch (e) {
break;
}
}
}
/**
* Clean up extracted text - removes HTML tags, decodes entities, cleans whitespace
*/
export function cleanText(text: string): string {
let cleaned = text;
@@ -292,7 +438,9 @@ async function extractFromEmbeddedJSON(
}
// Try __additionalDataLoaded pattern
const additionalDataMatch = content.match(/window\.__additionalDataLoaded\([^,]+,\s*(\{.+?\})\);/s);
const additionalDataMatch = content.match(
/window\.__additionalDataLoaded\([^,]+,\s*(\{.+?\})\);/s
);
if (additionalDataMatch) {
console.log(`[Extractor] Found __additionalDataLoaded in script ${i}`);
try {
@@ -309,7 +457,9 @@ async function extractFromEmbeddedJSON(
// Try to find any large JSON with caption data (new Instagram format)
if ((content.includes('"caption"') || content.includes('"text"')) && content.length > 10000) {
console.log(`[Extractor] Attempting to extract from large JSON in script ${i} (length: ${content.length})`);
console.log(
`[Extractor] Attempting to extract from large JSON in script ${i} (length: ${content.length})`
);
try {
// Try to parse as direct JSON
const jsonData = JSON.parse(content);
@@ -317,7 +467,9 @@ async function extractFromEmbeddedJSON(
// Try deep search first
const deepResult = deepSearchForCaption(jsonData);
if (deepResult && deepResult.bodyText && deepResult.bodyText.length > 130) {
console.log(`[Extractor] Deep search in JSON found caption: ${deepResult.bodyText.length} chars`);
console.log(
`[Extractor] Deep search in JSON found caption: ${deepResult.bodyText.length} chars`
);
const thumbnail = await extractThumbnailStealth(page, progressCallback);
return { ...deepResult, thumbnail };
}
@@ -325,7 +477,9 @@ async function extractFromEmbeddedJSON(
// Try standard parsing
const result = parseInstagramData(jsonData);
if (result && result.bodyText && result.bodyText.length > 130) {
console.log(`[Extractor] Successfully extracted from JSON, text length: ${result.bodyText.length}`);
console.log(
`[Extractor] Successfully extracted from JSON, text length: ${result.bodyText.length}`
);
const thumbnail = await extractThumbnailStealth(page, progressCallback);
return { ...result, thumbnail };
}
@@ -334,9 +488,9 @@ async function extractFromEmbeddedJSON(
console.log(`[Extractor] JSON parse failed, trying regex extraction...`);
// Try multiple patterns for different Instagram JSON structures
const patterns = [
/"caption"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/, // Escaped quotes
/"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"\s*,?\s*"pk"/, // text field near pk
/"edge_media_to_caption"\s*:\s*\{\s*"edges"\s*:\s*\[\s*\{\s*"node"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/,
/"caption"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/, // Escaped quotes
/"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"\s*,?\s*"pk"/, // text field near pk
/"edge_media_to_caption"\s*:\s*\{\s*"edges"\s*:\s*\[\s*\{\s*"node"\s*:\s*\{\s*"text"\s*:\s*"([^"\\]*(\\.[^"\\]*)*)"/
];
for (const pattern of patterns) {
@@ -347,11 +501,15 @@ async function extractFromEmbeddedJSON(
const captionText = rawText
.replace(/\\n/g, '\n')
.replace(/\\"/g, '"')
.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) =>
String.fromCharCode(parseInt(code, 16))
)
.replace(/\\\\/g, '\\');
if (captionText.length > 130) {
console.log(`[Extractor] Extracted caption from regex pattern, length: ${captionText.length}`);
console.log(
`[Extractor] Extracted caption from regex pattern, length: ${captionText.length}`
);
const thumbnail = await extractThumbnailStealth(page, progressCallback);
return { bodyText: cleanText(captionText), thumbnail };
}
@@ -448,7 +606,9 @@ export async function extractFromHTMLSection(
const currentShortcode = extractShortcode(currentUrl);
console.log(`[Extractor] Current page URL: ${currentUrl}`);
console.log(`[Extractor] Target shortcode: ${targetShortcode}, Current shortcode: ${currentShortcode}`);
console.log(
`[Extractor] Target shortcode: ${targetShortcode}, Current shortcode: ${currentShortcode}`
);
if (targetShortcode && currentShortcode !== targetShortcode) {
console.log(`[Extractor] URL mismatch: expected ${targetShortcode}, got ${currentShortcode}`);
@@ -458,62 +618,13 @@ export async function extractFromHTMLSection(
console.log(`[Extractor] Confirmed on correct post: ${currentShortcode}`);
// Wait for network to settle
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
await page.waitForTimeout(2000);
await page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.PAGE_LOAD });
await page.waitForTimeout(TIMEOUTS.NETWORK_SETTLE);
//Try to expand truncated caption by clicking "more" button
// Try to expand truncated caption by clicking "more" button
// STRATEGY: Since we're already on the correct page (URL validated above),
// the FIRST article/main post container should be our target post.
// Instagram uses JS routing so links don't have shortcodes in hrefs.
console.log('[Extractor] Looking for "more" button in primary post container...');
try {
// Wait for content to load
await page.waitForTimeout(1500);
// Find the MAIN post container - should be the first article or main content area
const mainContainer = page.locator('article, main, [role="main"]').first();
const containerExists = await mainContainer.count() > 0;
if (containerExists) {
console.log('[Extractor] Found main post container, searching for "more" button...');
// Try different patterns for the "more" button within the main container
const morePatterns = [
{ locator: mainContainer.locator('span').filter({ hasText: /\.\.\.\s*more/i }), desc: "span with '...more'" },
{ locator: mainContainer.locator('span').filter({ hasText: /…\s*more/i }), desc: "span with '… more'" },
{ locator: mainContainer.locator('div[role="button"]').filter({ hasText: /more/i }), desc: "button with 'more'" },
{ locator: mainContainer.locator('span[role="button"]').filter({ hasText: /more/i }), desc: "span button with 'more'" }
];
for (const pattern of morePatterns) {
const count = await pattern.locator.count();
console.log(`[Extractor] Checking ${pattern.desc}: found ${count}`);
if (count > 0) {
const firstMore = pattern.locator.first();
try {
if (await firstMore.isVisible({ timeout: 1000 })) {
const text = await firstMore.textContent();
console.log(`[Extractor] Found visible "more": "${text}"`);
await firstMore.click();
console.log('[Extractor] Clicked "more" - waiting for expansion...');
await page.waitForTimeout(3000);
console.log('[Extractor] Caption expansion complete');
break; // Success!
}
} catch (e) {
console.log(`[Extractor] ${pattern.desc} not clickable: ${e}`);
}
}
}
} else {
console.log('[Extractor] No main container found');
}
console.log('[Extractor] Finished "more" button expansion attempt');
} catch (e) {
console.log(`[Extractor] Error while trying to expand caption: ${e}`);
}
await tryExpandCaptionInHTMLSection(page);
console.log('[Extractor] Extracting caption using intelligent span detection...');
@@ -538,9 +649,10 @@ export async function extractFromHTMLSection(
// If we found links to the post, search for spans within those link ancestors
const searchRoots: Element[] = [];
if (postLinks.length > 0) {
postLinks.forEach(link => {
postLinks.forEach((link) => {
// Get the article or section container for this post
let container = link.closest('article') || link.closest('section') || link.closest('[role="main"]');
let container =
link.closest('article') || link.closest('section') || link.closest('[role="main"]');
if (container && !searchRoots.includes(container)) {
searchRoots.push(container);
console.log(`[Extractor] Found container for target post`);
@@ -555,8 +667,8 @@ export async function extractFromHTMLSection(
}
const spans: HTMLElement[] = [];
searchRoots.forEach(root => {
root.querySelectorAll('span').forEach(span => spans.push(span as HTMLElement));
searchRoots.forEach((root) => {
root.querySelectorAll('span').forEach((span) => spans.push(span as HTMLElement));
});
console.log(`[Extractor] Searching ${spans.length} spans for recipe content`);
@@ -584,7 +696,7 @@ export async function extractFromHTMLSection(
score += brCount * 100; // Massive weight for line breaks
// Check for recipe keywords (strong indicator)
const hasKeywords = recipeKeywords.some(keyword => text.includes(keyword));
const hasKeywords = recipeKeywords.some((keyword) => text.includes(keyword));
if (hasKeywords) {
score += 500; // Huge boost for recipe keywords
}
@@ -616,7 +728,9 @@ export async function extractFromHTMLSection(
// Update best candidate
if (score > 0 && (!bestCandidate || score > bestCandidate.score)) {
console.log(`[Extractor] New best: score=${score}, len=${text.length}, br=${brCount}, links=${linkCount}, preview="${text.substring(0, 80)}..."`);
console.log(
`[Extractor] New best: score=${score}, len=${text.length}, br=${brCount}, links=${linkCount}, preview="${text.substring(0, 80)}..."`
);
bestCandidate = {
element: span,
text: span.textContent || '',
@@ -638,7 +752,9 @@ export async function extractFromHTMLSection(
// Explicit type assertion (safe after null guard)
const candidate: CaptionCandidate = bestCandidate;
console.log(`[Extractor] Final caption candidate: score=${candidate.score}, length=${candidate.text.length}`);
console.log(
`[Extractor] Final caption candidate: score=${candidate.score}, length=${candidate.text.length}`
);
// Extract text from the best candidate
// Use innerHTML to preserve <br> tags, which will be converted to newlines in cleanText
@@ -698,15 +814,15 @@ export async function extractFromDOM(
try {
// Give Instagram more time to load dynamic content
console.log('[Extractor] Waiting for network idle...');
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
await page.waitForLoadState('networkidle', { timeout: TIMEOUTS.PAGE_LOAD }).catch(() => {
console.log('[Extractor] Network idle timeout, continuing anyway');
});
// Try to wait for article content
await page.waitForSelector('article', { timeout: 5000 }).catch(() => {});
await page.waitForSelector('article', { timeout: TIMEOUTS.ARTICLE_SELECTOR }).catch(() => {});
// Additional wait for dynamic content
await page.waitForTimeout(2000);
await page.waitForTimeout(TIMEOUTS.NETWORK_SETTLE);
// Try to intercept GraphQL responses
let graphqlCaption: string | null = null;
@@ -715,11 +831,12 @@ export async function extractFromDOM(
if (url.includes('graphql') || url.includes('api/v1')) {
try {
const json = await response.json();
// Try to find caption in the response
const captionData = extractCaptionFromGraphQL(json);
if (captionData && captionData.length > 130) {
graphqlCaption = captionData;
console.log(`[Extractor] Intercepted GraphQL response with ${captionData.length} chars`);
console.log(
`[Extractor] Intercepted GraphQL response with ${captionData.length} chars`
);
}
} catch (e) {
// Not JSON or parsing failed
@@ -727,54 +844,15 @@ export async function extractFromDOM(
}
});
// Wait a bit for any GraphQL requests to complete
await page.waitForTimeout(1000);
await page.waitForTimeout(TIMEOUTS.GRAPHQL_WAIT);
if (graphqlCaption) {
const thumbnail = await extractThumbnailStealth(page, progressCallback);
return { bodyText: cleanText(graphqlCaption), thumbnail };
}
// First, try to expand truncated captions by clicking "more" button
// Try multiple times with different selectors
let expandAttempts = 0;
const maxExpandAttempts = 3;
while (expandAttempts < maxExpandAttempts) {
try {
const moreButtonSelectors = [
'article button:has-text("more")',
'article button:has-text("More")',
'article button:has-text("… more")',
'article span[role="button"]:has-text("more")',
'article [role="button"]:has-text("more")',
'article div[role="button"]:has-text("more")',
'xpath=//article//span[contains(text(), "more")]/..',
'xpath=//article//button[contains(., "more")]'
];
let clicked = false;
for (const selector of moreButtonSelectors) {
try {
const button = page.locator(selector).first();
if (await button.isVisible({ timeout: 500 })) {
await button.click();
await page.waitForTimeout(800);
console.log(`[Extractor] Clicked "more" button with selector: ${selector}`);
clicked = true;
expandAttempts++;
break;
}
} catch (e) {
// Try next selector
}
}
if (!clicked) break; // No more buttons found
} catch (e) {
break;
}
}
// Try to expand truncated captions by clicking "more" button
await tryExpandCaptionInDOM(page);
const captionText = await page.evaluate(() => {
// First check og:description for comparison
@@ -787,7 +865,9 @@ export async function extractFromDOM(
// SMART APPROACH: Find the truncated text first, then look for full version nearby
// Look for text that ends with "..." or "… more"
const allSpans = Array.from(document.querySelectorAll('article span, article div, article h1'));
const allSpans = Array.from(
document.querySelectorAll('article span, article div, article h1')
);
let longestText = '';
let matchedElement = null;
@@ -809,11 +889,14 @@ export async function extractFromDOM(
}
// Strategy 2: Look in data attributes
const elementsWithData = Array.from(document.querySelectorAll('[data-caption], [data-text], [data-content]'));
const elementsWithData = Array.from(
document.querySelectorAll('[data-caption], [data-text], [data-content]')
);
for (const el of elementsWithData) {
const dataCaption = el.getAttribute('data-caption') ||
el.getAttribute('data-text') ||
el.getAttribute('data-content');
const dataCaption =
el.getAttribute('data-caption') ||
el.getAttribute('data-text') ||
el.getAttribute('data-content');
if (dataCaption && dataCaption.length > longestText.length) {
longestText = dataCaption;
console.log(`[Extractor] Found data attribute with ${dataCaption.length} chars`);
@@ -821,7 +904,11 @@ export async function extractFromDOM(
}
// Strategy 3: Look for hidden/collapsed content
const hiddenElements = Array.from(document.querySelectorAll('[style*="display: none"], [style*="display:none"], .collapsed, [aria-hidden="true"]'));
const hiddenElements = Array.from(
document.querySelectorAll(
'[style*="display: none"], [style*="display:none"], .collapsed, [aria-hidden="true"]'
)
);
for (const el of hiddenElements) {
const text = el.textContent?.trim() || '';
if (text.length > longestText.length && text.length > 200) {
@@ -838,7 +925,9 @@ export async function extractFromDOM(
const parentText = parent.textContent?.trim() || '';
if (parentText.length > longestText.length) {
longestText = parentText;
console.log(`[Extractor] Found fuller text in parent element: ${parentText.length} chars`);
console.log(
`[Extractor] Found fuller text in parent element: ${parentText.length} chars`
);
}
}
@@ -864,7 +953,10 @@ export async function extractFromDOM(
// Fallback to og:description
if (metaDesc) {
const content = ogContent;
const cleanedContent = content.replace(/^\d+K?\s+likes,\s+\d+\s+comments\s+-\s+[\w.]+\s+on\s+[^:]+:\s*["']?/, '');
const cleanedContent = content.replace(
/^\d+K?\s+likes,\s+\d+\s+comments\s+-\s+[\w.]+\s+on\s+[^:]+:\s*["']?/,
''
);
console.log('[Extractor] DOM selector fallback: og:description (with metadata cleanup)');
return cleanedContent;
}
@@ -1021,11 +1113,15 @@ async function extractFromInternalState(
}
if (result && result.bodyText && result.bodyText.length > 130) {
console.log(`[Extractor] Successfully extracted from ${stateData.key}, length: ${result.bodyText.length}`);
console.log(
`[Extractor] Successfully extracted from ${stateData.key}, length: ${result.bodyText.length}`
);
const thumbnail = await extractThumbnailStealth(page, progressCallback);
return { ...result, thumbnail };
} else if (result?.bodyText) {
console.log(`[Extractor] Found text in ${stateData.key} but it's truncated (${result.bodyText.length} chars)`);
console.log(
`[Extractor] Found text in ${stateData.key} but it's truncated (${result.bodyText.length} chars)`
);
}
} catch (e) {
console.log(`[Extractor] Failed to parse ${stateData.key}:`, e);
@@ -1042,7 +1138,11 @@ async function extractFromInternalState(
/**
* Deep search for caption text in any nested object structure
*/
function deepSearchForCaption(obj: any, maxDepth = 10, currentDepth = 0): Omit<ExtractedContent, 'thumbnail'> | null {
function deepSearchForCaption(
obj: any,
maxDepth = 10,
currentDepth = 0
): Omit<ExtractedContent, 'thumbnail'> | null {
if (currentDepth > maxDepth || !obj || typeof obj !== 'object') {
return null;
}
@@ -1208,108 +1308,118 @@ export async function extractTextAndThumbnail(
timestamp: new Date().toISOString()
});
return withRetry(async () => {
const authPath = resolveAuthPath();
const context = await createBrowserContext(authPath);
const page = await context.newPage();
return withRetry(
async () => {
const authPath = resolveAuthPath();
const context = await createBrowserContext(authPath);
const page = await context.newPage();
// Extract shortcode for validation
const expectedShortcode = extractShortcode(url);
console.log(`[Extractor] Target shortcode: ${expectedShortcode || 'unknown'}`);
// Extract shortcode for validation
const expectedShortcode = extractShortcode(url);
console.log(`[Extractor] Target shortcode: ${expectedShortcode || 'unknown'}`);
try {
// Set timeout
page.setDefaultTimeout(30000);
try {
// Set timeout
page.setDefaultTimeout(30000);
// Set up GraphQL response interception BEFORE loading the page
// This is critical to catch initial network requests during page load
let interceptedCaption: string | null = null;
page.on('response', async (response) => {
try {
const responseUrl = response.url();
if (responseUrl.includes('graphql') || responseUrl.includes('api/v1') || responseUrl.includes('/web/')) {
try {
const json = await response.json();
const captionData = extractCaptionFromGraphQL(json, expectedShortcode ?? undefined);
if (captionData && captionData.length > 130) {
interceptedCaption = captionData;
console.log(`[Extractor] ✓ Intercepted GraphQL with full caption: ${captionData.length} chars (shortcode verified)`);
// Set up GraphQL response interception BEFORE loading the page
// This is critical to catch initial network requests during page load
let interceptedCaption: string | null = null;
page.on('response', async (response) => {
try {
const responseUrl = response.url();
if (
responseUrl.includes('graphql') ||
responseUrl.includes('api/v1') ||
responseUrl.includes('/web/')
) {
try {
const json = await response.json();
const captionData = extractCaptionFromGraphQL(json, expectedShortcode ?? undefined);
if (captionData && captionData.length > 130) {
interceptedCaption = captionData;
console.log(
`[Extractor] ✓ Intercepted GraphQL with full caption: ${captionData.length} chars (shortcode verified)`
);
}
} catch (e) {
// Not JSON or parse error, skip
}
} catch (e) {
// Not JSON or parse error, skip
}
} catch (e) {
// Ignore response errors
}
} catch (e) {
// Ignore response errors
}
});
});
onProgress?.({
type: 'status',
message: 'Loading Instagram page...',
timestamp: new Date().toISOString()
});
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Add small human-like delay
await page.waitForTimeout(1000 + Math.random() * 2000);
// Try scrolling and waiting to trigger additional GraphQL requests
console.log('[Extractor] Scrolling to trigger lazy loading...');
await page.evaluate(() => {
window.scrollBy(0, 300);
});
await page.waitForTimeout(1500);
await page.evaluate(() => {
window.scrollBy(0, 300);
});
await page.waitForTimeout(1500);
await page.evaluate(() => {
window.scrollTo(0, 0);
});
await page.waitForTimeout(1000);
// If we intercepted a full caption, use it immediately
if (interceptedCaption) {
console.log('[Extractor] Using intercepted caption from network traffic');
const thumbnail = await extractThumbnailStealth(page, onProgress);
onProgress?.({
type: 'complete',
message: 'Extraction completed via GraphQL interception',
method: 'graphql-intercept',
type: 'status',
message: 'Loading Instagram page...',
timestamp: new Date().toISOString()
});
return { bodyText: cleanText(interceptedCaption), thumbnail };
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Add small human-like delay
await page.waitForTimeout(1000 + Math.random() * 2000);
// Try scrolling and waiting to trigger additional GraphQL requests
console.log('[Extractor] Scrolling to trigger lazy loading...');
await page.evaluate(() => {
window.scrollBy(0, 300);
});
await page.waitForTimeout(1500);
await page.evaluate(() => {
window.scrollBy(0, 300);
});
await page.waitForTimeout(1500);
await page.evaluate(() => {
window.scrollTo(0, 0);
});
await page.waitForTimeout(1000);
// If we intercepted a full caption, use it immediately
if (interceptedCaption) {
console.log('[Extractor] Using intercepted caption from network traffic');
const thumbnail = await extractThumbnailStealth(page, onProgress);
onProgress?.({
type: 'complete',
message: 'Extraction completed via GraphQL interception',
method: 'graphql-intercept',
timestamp: new Date().toISOString()
});
return { bodyText: cleanText(interceptedCaption), thumbnail };
}
const result = await extractWithStrategies(url, page, context, onProgress);
if (!result.success || !result.data) {
throw new Error(result.error || 'Extraction failed');
}
// Save debug content
fs.writeFileSync(
path.resolve('debug_page.txt'),
`Method: ${result.method}\n\n${result.data.bodyText}`
);
onProgress?.({
type: 'complete',
message: 'Extraction completed successfully',
method: result.method,
timestamp: new Date().toISOString()
});
return result.data;
} finally {
await page.close();
await context.close();
}
const result = await extractWithStrategies(url, page, context, onProgress);
if (!result.success || !result.data) {
throw new Error(result.error || 'Extraction failed');
}
// Save debug content
fs.writeFileSync(
path.resolve('debug_page.txt'),
`Method: ${result.method}\n\n${result.data.bodyText}`
);
onProgress?.({
type: 'complete',
message: 'Extraction completed successfully',
method: result.method,
timestamp: new Date().toISOString()
});
return result.data;
} finally {
await page.close();
await context.close();
}
}, DEFAULT_RETRY_CONFIG, onProgress);
},
DEFAULT_RETRY_CONFIG,
onProgress
);
}
/**

View File

@@ -10,230 +10,234 @@ import webpush from 'web-push';
import { queueConfig } from '../queue/config';
interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
interface NotificationPayload {
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tag?: string;
requireInteraction?: boolean;
analytics?: any;
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tag?: string;
requireInteraction?: boolean;
analytics?: any;
}
class PushNotificationService {
private subscriptions = new Map<string, PushSubscription>();
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
private subscriptions = new Map<string, PushSubscription>();
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
constructor() {
this.loadVapidKeys();
constructor() {
this.loadVapidKeys();
// Configure web-push with VAPID details
if (this.vapidKeys) {
webpush.setVapidDetails(
queueConfig.push.vapidEmail,
this.vapidKeys.publicKey,
this.vapidKeys.privateKey
);
}
}
// Configure web-push with VAPID details
if (this.vapidKeys) {
webpush.setVapidDetails(
queueConfig.push.vapidEmail,
this.vapidKeys.publicKey,
this.vapidKeys.privateKey
);
}
}
/**
* Load VAPID keys for push notifications
* In production, these should be stored securely and loaded from environment
*/
private loadVapidKeys() {
// Load from config module which uses SvelteKit's $env/dynamic/private
this.vapidKeys = {
publicKey: queueConfig.push.vapidPublicKey,
privateKey: queueConfig.push.vapidPrivateKey
};
}
/**
* Load VAPID keys for push notifications
* In production, these should be stored securely and loaded from environment
*/
private loadVapidKeys() {
// Load from config module which uses SvelteKit's $env/dynamic/private
this.vapidKeys = {
publicKey: queueConfig.push.vapidPublicKey,
privateKey: queueConfig.push.vapidPrivateKey
};
}
/**
* Get the public VAPID key for client-side subscription
*/
getPublicVapidKey(): string | null {
return this.vapidKeys?.publicKey || null;
}
/**
* Get the public VAPID key for client-side subscription
*/
getPublicVapidKey(): string | null {
return this.vapidKeys?.publicKey || null;
}
/**
* Subscribe a client to push notifications
*/
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
console.log(`[PushService] Subscribing client ${clientId}`);
this.subscriptions.set(clientId, subscription);
/**
* Subscribe a client to push notifications
*/
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
console.log(`[PushService] Subscribing client ${clientId}`);
this.subscriptions.set(clientId, subscription);
// In production, store subscriptions in database
// For development, we'll keep them in memory
}
// In production, store subscriptions in database
// For development, we'll keep them in memory
}
/**
* Unsubscribe a client from push notifications
*/
async unsubscribe(clientId: string): Promise<void> {
console.log(`[PushService] Unsubscribing client ${clientId}`);
this.subscriptions.delete(clientId);
}
/**
* Unsubscribe a client from push notifications
*/
async unsubscribe(clientId: string): Promise<void> {
console.log(`[PushService] Unsubscribing client ${clientId}`);
this.subscriptions.delete(clientId);
}
/**
* Send notification to all subscribed clients
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
if (this.subscriptions.size === 0) {
console.log('[PushService] No subscriptions, skipping notification');
return;
}
/**
* Send notification to all subscribed clients
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
if (this.subscriptions.size === 0) {
console.log('[PushService] No subscriptions, skipping notification');
return;
}
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
console.log(`[PushService] Notification payload:`, payload);
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
console.log(`[PushService] Notification payload:`, payload);
// In a real implementation, this would use web-push library
// For development/demo purposes, we'll simulate the notification
const notificationData = {
...payload,
timestamp: new Date().toISOString()
};
// In a real implementation, this would use web-push library
// For development/demo purposes, we'll simulate the notification
const notificationData = {
...payload,
timestamp: new Date().toISOString()
};
for (const [clientId, subscription] of this.subscriptions) {
try {
await this.sendToSubscription(subscription, notificationData);
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
} catch (error) {
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
// Remove invalid subscriptions
this.subscriptions.delete(clientId);
}
}
}
for (const [clientId, subscription] of this.subscriptions) {
try {
await this.sendToSubscription(subscription, notificationData);
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
} catch (error) {
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
// Remove invalid subscriptions
this.subscriptions.delete(clientId);
}
}
}
/**
* Send notification to specific subscription
*/
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
try {
const payload = JSON.stringify(data);
/**
* Send notification to specific subscription
*/
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
try {
const payload = JSON.stringify(data);
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload,
{
TTL: 60 * 60 * 24, // 24 hours
}
);
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload,
{
TTL: 60 * 60 * 24 // 24 hours
}
);
console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`);
} catch (error) {
// Check if subscription is expired/invalid
if ((error as any).statusCode === 410) {
console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`);
throw new Error('Subscription expired');
}
console.log(
`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`
);
} catch (error) {
// Check if subscription is expired/invalid
if ((error as any).statusCode === 410) {
console.warn(
`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`
);
throw new Error('Subscription expired');
}
console.error('[PushService] Failed to send notification:', {
endpoint: subscription.endpoint.substring(0, 50) + '...',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
console.error('[PushService] Failed to send notification:', {
endpoint: subscription.endpoint.substring(0, 50) + '...',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Send success notification when recipe extraction completes
*/
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName
? `Recipe "${recipeName}" has been extracted and saved successfully!`
: 'Your recipe extraction is complete and ready to view.',
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_complete',
itemId,
timestamp: Date.now()
}
};
/**
* Send success notification when recipe extraction completes
*/
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName
? `Recipe "${recipeName}" has been extracted and saved successfully!`
: 'Your recipe extraction is complete and ready to view.',
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_complete',
itemId,
timestamp: Date.now()
}
};
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
}
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
}
await this.sendNotification(payload);
}
await this.sendNotification(payload);
}
/**
* Send error notification when recipe extraction fails
*/
async notifyError(itemId: string, error: string): Promise<void> {
const payload: NotificationPayload = {
type: 'error',
itemId,
body: `Recipe extraction failed: ${error}. Tap to retry.`,
tag: `recipe-error-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_failed',
itemId,
error,
timestamp: Date.now()
}
};
/**
* Send error notification when recipe extraction fails
*/
async notifyError(itemId: string, error: string): Promise<void> {
const payload: NotificationPayload = {
type: 'error',
itemId,
body: `Recipe extraction failed: ${error}. Tap to retry.`,
tag: `recipe-error-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_failed',
itemId,
error,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
await this.sendNotification(payload);
}
/**
* Send progress notification for long-running extractions
*/
async notifyProgress(itemId: string, phase: string): Promise<void> {
const payload: NotificationPayload = {
type: 'progress',
itemId,
body: `Recipe extraction in progress: ${phase}`,
tag: `recipe-progress-${itemId}`,
requireInteraction: false,
analytics: {
event: 'recipe_extraction_progress',
itemId,
phase,
timestamp: Date.now()
}
};
/**
* Send progress notification for long-running extractions
*/
async notifyProgress(itemId: string, phase: string): Promise<void> {
const payload: NotificationPayload = {
type: 'progress',
itemId,
body: `Recipe extraction in progress: ${phase}`,
tag: `recipe-progress-${itemId}`,
requireInteraction: false,
analytics: {
event: 'recipe_extraction_progress',
itemId,
phase,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
await this.sendNotification(payload);
}
/**
* Get subscription count for monitoring
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
/**
* Get subscription count for monitoring
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
/**
* Clear all subscriptions (for testing/cleanup)
*/
clearAllSubscriptions(): void {
console.log('[PushService] Clearing all subscriptions');
this.subscriptions.clear();
}
/**
* Clear all subscriptions (for testing/cleanup)
*/
clearAllSubscriptions(): void {
console.log('[PushService] Clearing all subscriptions');
this.subscriptions.clear();
}
}
// Singleton instance

View File

@@ -8,13 +8,15 @@ const RecipeSchema = z.object({
name: z.string(),
servings: z.number().nullable(),
description: z.string().nullable(),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
})
).nullable(),
ingredients: z
.array(
z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
})
)
.nullable(),
steps: z.array(z.string()).nullable(),
image: z.string().nullable().optional()
});
@@ -59,9 +61,9 @@ export async function detectRecipe(text: string): Promise<boolean> {
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError = errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') ||
errorMessage.toLowerCase().includes('load'));
const isModelError =
errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
@@ -116,9 +118,9 @@ export async function parseRecipe(text: string): Promise<Recipe> {
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError = errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') ||
errorMessage.toLowerCase().includes('load'));
const isModelError =
errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
@@ -129,8 +131,10 @@ export async function parseRecipe(text: string): Promise<Recipe> {
}
// If structured output fails, try standard completion
if ((e as any).message?.includes('response_format') ||
(e as any).message?.includes('structured output')) {
if (
(e as any).message?.includes('response_format') ||
(e as any).message?.includes('structured output')
) {
console.warn('[LLM] Falling back to standard completion');
return await parseRecipeWithStandardCompletion(text);
}

View File

@@ -41,397 +41,398 @@ import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback
* ```
*/
export class QueueManager {
/** Map of queue items by ID */
private items: Map<string, QueueItem> = new Map();
/** Map of queue items by ID */
private items: Map<string, QueueItem> = new Map();
/** Set of subscriber callbacks */
private subscribers: Set<QueueUpdateCallback> = new Set();
/** Set of subscriber callbacks */
private subscribers: Set<QueueUpdateCallback> = new Set();
/**
* Add URL to processing queue
*
* @param url - Instagram URL to process
* @returns Newly created queue item
*
* @example
* ```typescript
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
* console.log('Queued with ID:', item.id);
* ```
*/
enqueue(url: string): QueueItem {
const now = new Date().toISOString();
const item: QueueItem = {
id: uuidv4(),
url,
status: 'pending',
enqueuedAt: now,
createdAt: now,
updatedAt: now,
phases: [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
],
logs: [],
progressEvents: [],
retryCount: 0,
maxRetries: 3
};
/**
* Add URL to processing queue
*
* @param url - Instagram URL to process
* @returns Newly created queue item
*
* @example
* ```typescript
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
* console.log('Queued with ID:', item.id);
* ```
*/
enqueue(url: string): QueueItem {
const now = new Date().toISOString();
const item: QueueItem = {
id: uuidv4(),
url,
status: 'pending',
enqueuedAt: now,
createdAt: now,
updatedAt: now,
phases: [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
],
logs: [],
progressEvents: [],
retryCount: 0,
maxRetries: 3
};
this.items.set(item.id, item);
this.notifySubscribers({
type: 'status_change',
itemId: item.id,
status: 'pending',
url: item.url,
timestamp: now,
progress: item.phases
});
this.items.set(item.id, item);
this.notifySubscribers({
type: 'status_change',
itemId: item.id,
status: 'pending',
url: item.url,
timestamp: now,
progress: item.phases
});
return item;
}
return item;
}
/**
* Get next pending item for processing (FIFO)
*
* Automatically marks the item as in_progress when dequeued.
*
* @returns Next pending item, or null if queue is empty
*
* @example
* ```typescript
* const item = queueManager.dequeue();
* if (item) {
* // Process item
* console.log('Processing:', item.url);
* }
* ```
*/
dequeue(): QueueItem | null {
for (const item of this.items.values()) {
if (item.status === 'pending') {
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
return item;
}
}
return null;
}
/**
* Get next pending item for processing (FIFO)
*
* Automatically marks the item as in_progress when dequeued.
*
* @returns Next pending item, or null if queue is empty
*
* @example
* ```typescript
* const item = queueManager.dequeue();
* if (item) {
* // Process item
* console.log('Processing:', item.url);
* }
* ```
*/
dequeue(): QueueItem | null {
for (const item of this.items.values()) {
if (item.status === 'pending') {
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
return item;
}
}
return null;
}
/**
* Update item status and optional data
*
* Handles status-specific logic:
* - Sets startedAt when transitioning to in_progress
* - Sets completedAt when transitioning to success/error
* - Updates currentPhase for in_progress status
*
* @param itemId - ID of item to update
* @param status - New status
* @param data - Optional additional data to merge into item
*
* @example
* ```typescript
* queueManager.updateStatus(itemId, 'in_progress', {
* phase: 'parsing'
* });
*
* queueManager.updateStatus(itemId, 'success', {
* recipe: parsedRecipe,
* tandoorRecipeId: 123
* });
* ```
*/
updateStatus(
itemId: string,
status: QueueItemStatus,
data?: any
): void {
const item = this.items.get(itemId);
if (!item) return;
/**
* Update item status and optional data
*
* Handles status-specific logic:
* - Sets startedAt when transitioning to in_progress
* - Sets completedAt when transitioning to success/error
* - Updates currentPhase for in_progress status
*
* @param itemId - ID of item to update
* @param status - New status
* @param data - Optional additional data to merge into item
*
* @example
* ```typescript
* queueManager.updateStatus(itemId, 'in_progress', {
* phase: 'parsing'
* });
*
* queueManager.updateStatus(itemId, 'success', {
* recipe: parsedRecipe,
* tandoorRecipeId: 123
* });
* ```
*/
updateStatus(itemId: string, status: QueueItemStatus, data?: any): void {
const item = this.items.get(itemId);
if (!item) return;
const now = new Date().toISOString();
item.status = status;
item.updatedAt = now;
const now = new Date().toISOString();
item.status = status;
item.updatedAt = now;
// Update phase progress
if (status === 'in_progress' && data?.phase) {
item.currentPhase = data.phase;
// Update phase progress
if (status === 'in_progress' && data?.phase) {
item.currentPhase = data.phase;
if (!item.startedAt) {
item.startedAt = now;
}
if (!item.startedAt) {
item.startedAt = now;
}
// Update phases array
const phaseIndex = item.phases.findIndex(p => p.name === data.phase);
if (phaseIndex >= 0) {
// Mark previous phases as completed
for (let i = 0; i < phaseIndex; i++) {
if (item.phases[i].status === 'in_progress') {
item.phases[i].status = 'completed';
item.phases[i].completedAt = now;
}
}
// Mark current phase as in progress
item.phases[phaseIndex].status = 'in_progress';
item.phases[phaseIndex].startedAt = now;
}
}
// Update phases array
const phaseIndex = item.phases.findIndex((p) => p.name === data.phase);
if (phaseIndex >= 0) {
// Mark previous phases as completed
for (let i = 0; i < phaseIndex; i++) {
if (item.phases[i].status === 'in_progress') {
item.phases[i].status = 'completed';
item.phases[i].completedAt = now;
}
}
// Mark current phase as in progress
item.phases[phaseIndex].status = 'in_progress';
item.phases[phaseIndex].startedAt = now;
}
}
if (status === 'success') {
item.completedAt = now;
// Mark all phases as completed
item.phases.forEach(phase => {
if (phase.status !== 'completed') {
phase.status = 'completed';
phase.completedAt = now;
}
});
}
if (status === 'success') {
item.completedAt = now;
// Mark all phases as completed
item.phases.forEach((phase) => {
if (phase.status !== 'completed') {
phase.status = 'completed';
phase.completedAt = now;
}
});
}
if (status === 'error' || status === 'unhealthy') {
item.completedAt = now;
// Mark current phase as error
if (item.currentPhase) {
const phaseIndex = item.phases.findIndex(p => p.name === item.currentPhase);
if (phaseIndex >= 0) {
item.phases[phaseIndex].status = 'error';
item.phases[phaseIndex].error = data?.error?.message;
}
}
}
if (status === 'error' || status === 'unhealthy') {
item.completedAt = now;
// Mark current phase as error
if (item.currentPhase) {
const phaseIndex = item.phases.findIndex((p) => p.name === item.currentPhase);
if (phaseIndex >= 0) {
item.phases[phaseIndex].status = 'error';
item.phases[phaseIndex].error = data?.error?.message;
}
}
}
// Wrap results in results object
if (data?.extractedText || data?.thumbnail !== undefined || data?.recipe || data?.tandoorRecipeId) {
if (!item.results) {
item.results = {};
}
// Wrap results in results object
if (
data?.extractedText ||
data?.thumbnail !== undefined ||
data?.recipe ||
data?.tandoorRecipeId
) {
if (!item.results) {
item.results = {};
}
if (data.extractedText) {
item.results.extractedText = data.extractedText;
item.extractedText = data.extractedText; // Keep legacy
}
if (data.thumbnail !== undefined) {
item.results.thumbnail = data.thumbnail;
item.thumbnail = data.thumbnail; // Keep legacy
}
if (data.recipe) {
item.results.recipe = data.recipe;
item.recipe = data.recipe; // Keep legacy
}
if (data.tandoorRecipeId) {
item.results.tandoorRecipeId = data.tandoorRecipeId;
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
if (data.extractedText) {
item.results.extractedText = data.extractedText;
item.extractedText = data.extractedText; // Keep legacy
}
if (data.thumbnail !== undefined) {
item.results.thumbnail = data.thumbnail;
item.thumbnail = data.thumbnail; // Keep legacy
}
if (data.recipe) {
item.results.recipe = data.recipe;
item.recipe = data.recipe; // Keep legacy
}
if (data.tandoorRecipeId) {
item.results.tandoorRecipeId = data.tandoorRecipeId;
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
// Construct Tandoor URL
if (tandoorConfig.serverUrl) {
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
}
}
}
// Construct Tandoor URL
if (tandoorConfig.serverUrl) {
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
}
}
}
if (data?.error) {
item.error = data.error;
}
if (data?.error) {
item.error = data.error;
}
// Notify subscribers with enhanced update
this.notifySubscribers({
type: 'status_change',
itemId,
status,
timestamp: now,
url: item.url,
phase: item.currentPhase,
progress: item.phases,
results: item.results,
error: item.error,
...data
});
}
// Notify subscribers with enhanced update
this.notifySubscribers({
type: 'status_change',
itemId,
status,
timestamp: now,
url: item.url,
phase: item.currentPhase,
progress: item.phases,
results: item.results,
error: item.error,
...data
});
}
/**
* Add progress event to item's history
*
* Also extracts message into logs array for easy display.
*
* @param itemId - ID of item
* @param event - Progress event to add
*
* @example
* ```typescript
* queueManager.addProgressEvent(itemId, {
* type: 'status',
* message: 'Extracting from Instagram...',
* timestamp: new Date().toISOString()
* });
* ```
*/
addProgressEvent(itemId: string, event: any): void {
const item = this.items.get(itemId);
if (!item) return;
/**
* Add progress event to item's history
*
* Also extracts message into logs array for easy display.
*
* @param itemId - ID of item
* @param event - Progress event to add
*
* @example
* ```typescript
* queueManager.addProgressEvent(itemId, {
* type: 'status',
* message: 'Extracting from Instagram...',
* timestamp: new Date().toISOString()
* });
* ```
*/
addProgressEvent(itemId: string, event: any): void {
const item = this.items.get(itemId);
if (!item) return;
item.progressEvents.push(event);
item.logs.push(event.message);
item.progressEvents.push(event);
item.logs.push(event.message);
this.notifySubscribers({
type: 'progress',
itemId,
status: item.status,
timestamp: new Date().toISOString(),
data: { event }
});
}
this.notifySubscribers({
type: 'progress',
itemId,
status: item.status,
timestamp: new Date().toISOString(),
data: { event }
});
}
/**
* Remove item from queue
*
* @param itemId - ID of item to remove
* @returns true if item was removed, false if not found
*
* @example
* ```typescript
* const removed = queueManager.remove(itemId);
* if (removed) {
* console.log('Item removed successfully');
* }
* ```
*/
remove(itemId: string): boolean {
const deleted = this.items.delete(itemId);
if (deleted) {
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'error', // Use error to signal removal
timestamp: new Date().toISOString(),
data: { removed: true }
});
}
return deleted;
}
/**
* Remove item from queue
*
* @param itemId - ID of item to remove
* @returns true if item was removed, false if not found
*
* @example
* ```typescript
* const removed = queueManager.remove(itemId);
* if (removed) {
* console.log('Item removed successfully');
* }
* ```
*/
remove(itemId: string): boolean {
const deleted = this.items.delete(itemId);
if (deleted) {
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'error', // Use error to signal removal
timestamp: new Date().toISOString(),
data: { removed: true }
});
}
return deleted;
}
/**
* Retry a failed or unhealthy item
*
* Resets item to pending status and clears error state.
* Cannot retry items currently in progress.
*
* @param itemId - ID of item to retry
* @returns true if retry was initiated, false otherwise
*
* @example
* ```typescript
* const retried = queueManager.retry(itemId);
* if (retried) {
* console.log('Item queued for retry');
* } else {
* console.log('Cannot retry (item in progress or not found)');
* }
* ```
*/
retry(itemId: string): boolean {
const item = this.items.get(itemId);
if (!item || item.status === 'in_progress') return false;
/**
* Retry a failed or unhealthy item
*
* Resets item to pending status and clears error state.
* Cannot retry items currently in progress.
*
* @param itemId - ID of item to retry
* @returns true if retry was initiated, false otherwise
*
* @example
* ```typescript
* const retried = queueManager.retry(itemId);
* if (retried) {
* console.log('Item queued for retry');
* } else {
* console.log('Cannot retry (item in progress or not found)');
* }
* ```
*/
retry(itemId: string): boolean {
const item = this.items.get(itemId);
if (!item || item.status === 'in_progress') return false;
item.retryCount++;
item.status = 'pending';
item.currentPhase = undefined;
item.error = undefined;
item.startedAt = undefined;
item.completedAt = undefined;
item.retryCount++;
item.status = 'pending';
item.currentPhase = undefined;
item.error = undefined;
item.startedAt = undefined;
item.completedAt = undefined;
// Reset phases to pending
item.phases = [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
];
// Reset phases to pending
item.phases = [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
];
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'pending',
timestamp: new Date().toISOString(),
progress: item.phases,
data: { retryCount: item.retryCount }
});
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'pending',
timestamp: new Date().toISOString(),
progress: item.phases,
data: { retryCount: item.retryCount }
});
return true;
}
return true;
}
/**
* Get all queue items
*
* @returns Array of all queue items
*
* @example
* ```typescript
* const items = queueManager.getAll();
* console.log(`Queue has ${items.length} items`);
* ```
*/
getAll(): QueueItem[] {
return Array.from(this.items.values());
}
/**
* Get all queue items
*
* @returns Array of all queue items
*
* @example
* ```typescript
* const items = queueManager.getAll();
* console.log(`Queue has ${items.length} items`);
* ```
*/
getAll(): QueueItem[] {
return Array.from(this.items.values());
}
/**
* Get single item by ID
*
* @param itemId - ID of item to retrieve
* @returns Queue item or undefined if not found
*
* @example
* ```typescript
* const item = queueManager.get(itemId);
* if (item) {
* console.log('Status:', item.status);
* }
* ```
*/
get(itemId: string): QueueItem | undefined {
return this.items.get(itemId);
}
/**
* Get single item by ID
*
* @param itemId - ID of item to retrieve
* @returns Queue item or undefined if not found
*
* @example
* ```typescript
* const item = queueManager.get(itemId);
* if (item) {
* console.log('Status:', item.status);
* }
* ```
*/
get(itemId: string): QueueItem | undefined {
return this.items.get(itemId);
}
/**
* Subscribe to queue updates
*
* Callback will be called whenever any item is updated.
*
* @param callback - Function to call on each update
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = queueManager.subscribe((update) => {
* console.log('Update:', update.itemId, update.status);
* });
*
* // Later...
* unsubscribe();
* ```
*/
subscribe(callback: QueueUpdateCallback): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
/**
* Subscribe to queue updates
*
* Callback will be called whenever any item is updated.
*
* @param callback - Function to call on each update
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = queueManager.subscribe((update) => {
* console.log('Update:', update.itemId, update.status);
* });
*
* // Later...
* unsubscribe();
* ```
*/
subscribe(callback: QueueUpdateCallback): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
/**
* Notify all subscribers of an update
*
* Handles errors in individual subscribers to prevent one
* bad subscriber from affecting others.
*
* @param update - Update to broadcast
*/
private notifySubscribers(update: QueueStatusUpdate): void {
for (const callback of this.subscribers) {
try {
callback(update);
} catch (err) {
logError('[QueueManager] Subscriber error', err);
}
}
}
/**
* Notify all subscribers of an update
*
* Handles errors in individual subscribers to prevent one
* bad subscriber from affecting others.
*
* @param update - Update to broadcast
*/
private notifySubscribers(update: QueueStatusUpdate): void {
for (const callback of this.subscribers) {
try {
callback(update);
} catch (err) {
logError('[QueueManager] Subscriber error', err);
}
}
}
}
/**

View File

@@ -46,394 +46,396 @@ import type { QueueItem } from './types';
* ```
*/
export class QueueProcessor {
/** Whether processor is actively running */
private processing = false;
/** Whether processor is actively running */
private processing = false;
/** Maximum number of items to process simultaneously */
private concurrency = queueConfig.concurrency;
/** Maximum number of items to process simultaneously */
private concurrency = queueConfig.concurrency;
/** Number of workers currently processing items */
private activeWorkers = 0;
/** Number of workers currently processing items */
private activeWorkers = 0;
/** Unsubscribe function for queue manager subscription */
private unsubscribeFromQueue?: () => void;
/** Unsubscribe function for queue manager subscription */
private unsubscribeFromQueue?: () => void;
constructor() {
// Subscribe to queue updates to process new items immediately
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
// Trigger processing when new items are enqueued (status_change to 'pending')
if (update.type === 'status_change' && update.status === 'pending') {
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
// Use immediate processing (no timeout) for newly enqueued items
setTimeout(() => this.processNextBatch(), 0);
}
});
}
constructor() {
// Subscribe to queue updates to process new items immediately
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
// Trigger processing when new items are enqueued (status_change to 'pending')
if (update.type === 'status_change' && update.status === 'pending') {
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
// Use immediate processing (no timeout) for newly enqueued items
setTimeout(() => this.processNextBatch(), 0);
}
});
}
/**
* Start processing queue
*
* Begins dequeuing and processing items up to concurrency limit.
* Safe to call multiple times - will not start duplicates.
*/
start(): void {
if (this.processing) return;
this.processing = true;
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
this.processNextBatch();
}
/**
* Start processing queue
*
* Begins dequeuing and processing items up to concurrency limit.
* Safe to call multiple times - will not start duplicates.
*/
start(): void {
if (this.processing) return;
this.processing = true;
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
this.processNextBatch();
}
/**
* Stop processing queue
*
* Prevents new items from being dequeued.
* Items currently in progress will complete.
*/
stop(): void {
this.processing = false;
console.log('[QueueProcessor] Stopped');
/**
* Stop processing queue
*
* Prevents new items from being dequeued.
* Items currently in progress will complete.
*/
stop(): void {
this.processing = false;
console.log('[QueueProcessor] Stopped');
// Cleanup subscription when stopping
if (this.unsubscribeFromQueue) {
this.unsubscribeFromQueue();
this.unsubscribeFromQueue = undefined;
}
}
// Cleanup subscription when stopping
if (this.unsubscribeFromQueue) {
this.unsubscribeFromQueue();
this.unsubscribeFromQueue = undefined;
}
}
/**
* Process items up to concurrency limit
*
* Dequeues pending items and starts processing them.
* Automatically called recursively to maintain worker pool.
*/
private async processNextBatch(): Promise<void> {
if (!this.processing) return;
/**
* Process items up to concurrency limit
*
* Dequeues pending items and starts processing them.
* Automatically called recursively to maintain worker pool.
*/
private async processNextBatch(): Promise<void> {
if (!this.processing) return;
// Start new workers up to concurrency limit
while (this.activeWorkers < this.concurrency) {
const item = queueManager.dequeue();
if (!item) break;
// Start new workers up to concurrency limit
while (this.activeWorkers < this.concurrency) {
const item = queueManager.dequeue();
if (!item) break;
this.activeWorkers++;
console.log(`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
this.activeWorkers++;
console.log(
`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
);
this.processItem(item)
.finally(() => {
this.activeWorkers--;
console.log(`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
// Try to process next item immediately
setTimeout(() => this.processNextBatch(), 0);
});
}
this.processItem(item).finally(() => {
this.activeWorkers--;
console.log(
`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
);
// Try to process next item immediately
setTimeout(() => this.processNextBatch(), 0);
});
}
// Check again after shorter delay if still processing and no active workers
if (this.processing && this.activeWorkers === 0) {
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
}
}
// Check again after shorter delay if still processing and no active workers
if (this.processing && this.activeWorkers === 0) {
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
}
}
/**
* Process a single queue item through all phases
*
* Executes three phases sequentially:
* 1. Extraction - Extract content from Instagram
* 2. Parsing - Parse recipe from extracted text
* 3. Uploading - Upload to Tandoor (if configured)
*
* On success: marks item as 'success'
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
*
* @param item - Queue item to process
*/
private async processItem(item: QueueItem): Promise<void> {
try {
console.log(`[QueueProcessor] Processing ${item.url}`);
/**
* Process a single queue item through all phases
*
* Executes three phases sequentially:
* 1. Extraction - Extract content from Instagram
* 2. Parsing - Parse recipe from extracted text
* 3. Uploading - Upload to Tandoor (if configured)
*
* On success: marks item as 'success'
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
*
* @param item - Queue item to process
*/
private async processItem(item: QueueItem): Promise<void> {
try {
console.log(`[QueueProcessor] Processing ${item.url}`);
// Phase 1: Extraction
await this.extractionPhase(item);
// Phase 1: Extraction
await this.extractionPhase(item);
// Phase 2: Parsing
await this.parsingPhase(item);
// Phase 2: Parsing
await this.parsingPhase(item);
// Phase 3: Tandoor Upload (if enabled)
await this.uploadPhase(item);
// Phase 3: Tandoor Upload (if enabled)
await this.uploadPhase(item);
// Success
queueManager.updateStatus(item.id, 'success');
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
// Success
queueManager.updateStatus(item.id, 'success');
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
// Send push notification
await this.sendPushNotification(item, 'success');
// Send push notification
await this.sendPushNotification(item, 'success');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const recoverable = this.isRecoverableError(error);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const recoverable = this.isRecoverableError(error);
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
error: {
phase: item.currentPhase || 'extraction',
message: errorMsg,
recoverable,
timestamp: new Date().toISOString()
}
});
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
error: {
phase: item.currentPhase || 'extraction',
message: errorMsg,
recoverable,
timestamp: new Date().toISOString()
}
});
// Send push notification
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
}
}
// Send push notification
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
}
}
/**
* Phase 1: Extract text and thumbnail from Instagram
*
* Uses browser automation to load Instagram post and extract:
* - Recipe text (from caption, comments, etc.)
* - Thumbnail image (from meta tags or screenshot)
*
* Progress events are captured and added to queue item.
*
* @param item - Queue item being processed
* @throws Error if extraction fails
*/
private async extractionPhase(item: QueueItem): Promise<void> {
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction'
});
/**
* Phase 1: Extract text and thumbnail from Instagram
*
* Uses browser automation to load Instagram post and extract:
* - Recipe text (from caption, comments, etc.)
* - Thumbnail image (from meta tags or screenshot)
*
* Progress events are captured and added to queue item.
*
* @param item - Queue item being processed
* @throws Error if extraction fails
*/
private async extractionPhase(item: QueueItem): Promise<void> {
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction'
});
const progressCallback = (event: ProgressEvent) => {
queueManager.addProgressEvent(item.id, event);
};
const progressCallback = (event: ProgressEvent) => {
queueManager.addProgressEvent(item.id, event);
};
console.log(`[QueueProcessor] Extracting: ${item.url}`);
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
console.log(`[QueueProcessor] Extracting: ${item.url}`);
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction',
extractedText: extracted.bodyText,
thumbnail: extracted.thumbnail
});
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction',
extractedText: extracted.bodyText,
thumbnail: extracted.thumbnail
});
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
}
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
}
/**
* Phase 2: Parse recipe from extracted text
*
* Uses LLM to extract structured recipe data:
* - Recipe name
* - Ingredients with amounts and units
* - Instructions/steps
* - Servings, times, etc.
*
* Enriches recipe with metadata (URL, thumbnail).
*
* @param item - Queue item being processed
* @throws Error if parsing fails or no recipe found
*/
private async parsingPhase(item: QueueItem): Promise<void> {
if (!item.extractedText) {
throw new Error('No extracted text available for parsing');
}
/**
* Phase 2: Parse recipe from extracted text
*
* Uses LLM to extract structured recipe data:
* - Recipe name
* - Ingredients with amounts and units
* - Instructions/steps
* - Servings, times, etc.
*
* Enriches recipe with metadata (URL, thumbnail).
*
* @param item - Queue item being processed
* @throws Error if parsing fails or no recipe found
*/
private async parsingPhase(item: QueueItem): Promise<void> {
if (!item.extractedText) {
throw new Error('No extracted text available for parsing');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing'
});
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Parsing recipe with LLM...',
timestamp: new Date().toISOString()
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Parsing recipe with LLM...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
const recipe = await extractRecipe(item.extractedText);
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
const recipe = await extractRecipe(item.extractedText);
if (!recipe) {
throw new Error('Failed to parse recipe from extracted text');
}
if (!recipe) {
throw new Error('Failed to parse recipe from extracted text');
}
// Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${item.url}`;
} else {
recipe.description = `Link: ${item.url}`;
}
// Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${item.url}`;
} else {
recipe.description = `Link: ${item.url}`;
}
if (item.thumbnail) {
recipe.image = item.thumbnail;
}
if (item.thumbnail) {
recipe.image = item.thumbnail;
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing',
recipe
});
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing',
recipe
});
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
}
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
}
/**
* Phase 3: Upload to Tandoor (automatic)
*
* If Tandoor is configured (TANDOOR_TOKEN env var set):
* - Uploads recipe with ingredients and steps
* - Attempts to upload thumbnail/image
* - Image upload failure is non-fatal (logged but doesn't fail item)
*
* If Tandoor not configured: skips silently
*
* @param item - Queue item being processed
* @throws Error if Tandoor upload fails
*/
private async uploadPhase(item: QueueItem): Promise<void> {
// Check if Tandoor is enabled
if (!queueConfig.tandoor.enabled) {
// Skip if Tandoor not configured
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor not configured, skipping upload',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
return;
}
/**
* Phase 3: Upload to Tandoor (automatic)
*
* If Tandoor is configured (TANDOOR_TOKEN env var set):
* - Uploads recipe with ingredients and steps
* - Attempts to upload thumbnail/image
* - Image upload failure is non-fatal (logged but doesn't fail item)
*
* If Tandoor not configured: skips silently
*
* @param item - Queue item being processed
* @throws Error if Tandoor upload fails
*/
private async uploadPhase(item: QueueItem): Promise<void> {
// Check if Tandoor is enabled
if (!queueConfig.tandoor.enabled) {
// Skip if Tandoor not configured
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor not configured, skipping upload',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
return;
}
if (!item.recipe) {
throw new Error('No recipe available for upload');
}
if (!item.recipe) {
throw new Error('No recipe available for upload');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading'
});
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe to Tandoor...',
timestamp: new Date().toISOString()
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe to Tandoor...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
// Upload recipe
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
// Upload recipe
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
// Upload image if available
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
});
// Upload image if available
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
});
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor upload completed',
timestamp: new Date().toISOString()
});
}
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor upload completed',
timestamp: new Date().toISOString()
});
}
/**
* Determine if error is recoverable
*
* Recoverable errors (unhealthy):
* - Network timeouts
* - Connection failures
* - Image upload failures
* - Thumbnail extraction failures
*
* Non-recoverable errors (error):
* - Invalid URL format
* - Authentication failures
* - Parsing failures (no recipe found)
*
* @param error - Error to classify
* @returns true if error is recoverable, false otherwise
*/
private isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
/**
* Determine if error is recoverable
*
* Recoverable errors (unhealthy):
* - Network timeouts
* - Connection failures
* - Image upload failures
* - Thumbnail extraction failures
*
* Non-recoverable errors (error):
* - Invalid URL format
* - Authentication failures
* - Parsing failures (no recipe found)
*
* @param error - Error to classify
* @returns true if error is recoverable, false otherwise
*/
private isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
const message = error.message.toLowerCase();
// Recoverable errors
const recoverablePatterns = [
'timeout',
'network',
'econnrefused',
'enotfound',
'image upload failed',
'thumbnail',
'etimeout',
'fetch failed'
];
// Recoverable errors
const recoverablePatterns = [
'timeout',
'network',
'econnrefused',
'enotfound',
'image upload failed',
'thumbnail',
'etimeout',
'fetch failed'
];
return recoverablePatterns.some((pattern) => message.includes(pattern));
}
return recoverablePatterns.some(pattern => message.includes(pattern));
}
/**
* Send Web Push notification for queue item completion
*
* Sends appropriate notification based on processing status:
* - success: Recipe extraction complete with details
* - error/unhealthy: Extraction failed with retry option
*
* @param item - Queue item that completed
* @param status - Completion status (success, unhealthy, error)
*/
private async sendPushNotification(
item: QueueItem,
status: 'success' | 'unhealthy' | 'error'
): Promise<void> {
try {
switch (status) {
case 'success':
await pushNotificationService.notifySuccess(
item.id,
item.results?.recipe?.name,
item.results?.tandoorUrl
);
break;
/**
* Send Web Push notification for queue item completion
*
* Sends appropriate notification based on processing status:
* - success: Recipe extraction complete with details
* - error/unhealthy: Extraction failed with retry option
*
* @param item - Queue item that completed
* @param status - Completion status (success, unhealthy, error)
*/
private async sendPushNotification(
item: QueueItem,
status: 'success' | 'unhealthy' | 'error'
): Promise<void> {
try {
switch (status) {
case 'success':
await pushNotificationService.notifySuccess(
item.id,
item.results?.recipe?.name,
item.results?.tandoorUrl
);
break;
case 'error':
case 'unhealthy':
const errorMessage = item.error?.message || 'Processing failed';
await pushNotificationService.notifyError(item.id, errorMessage);
break;
case 'error':
case 'unhealthy':
const errorMessage = item.error?.message || 'Processing failed';
await pushNotificationService.notifyError(item.id, errorMessage);
break;
default:
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
}
} catch (error) {
logError('[QueueProcessor] Failed to send push notification', error);
// Don't let notification failures break processing
}
}
default:
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
}
} catch (error) {
logError('[QueueProcessor] Failed to send push notification', error);
// Don't let notification failures break processing
}
}
}
/**

View File

@@ -29,7 +29,9 @@ export const queueConfig = {
/** Web Push notification settings */
push: {
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPublicKey:
env.VAPID_PUBLIC_KEY ||
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: env.VAPID_EMAIL || 'mailto:admin@example.com'
}

View File

@@ -15,12 +15,7 @@ import type { ProgressEvent } from '$lib/server/extraction';
* - unhealthy: Recoverable error occurred, can be retried
* - error: Non-recoverable error occurred
*/
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'success'
| 'unhealthy'
| 'error';
export type QueueItemStatus = 'pending' | 'in_progress' | 'success' | 'unhealthy' | 'error';
/**
* Processing phases for queue items
@@ -28,26 +23,23 @@ export type QueueItemStatus =
* - parsing: Parsing recipe from extracted text
* - uploading: Uploading recipe to Tandoor
*/
export type ProcessingPhase =
| 'extraction'
| 'parsing'
| 'uploading';
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
/**
* Phase progress information
* Tracks the status of each processing phase
*/
export interface PhaseProgress {
/** Name of the phase */
name: ProcessingPhase;
/** Current status of this phase */
status: 'pending' | 'in_progress' | 'completed' | 'error';
/** When phase started processing (ISO 8601 string) */
startedAt?: string;
/** When phase completed (ISO 8601 string) */
completedAt?: string;
/** Error message if phase failed */
error?: string;
/** Name of the phase */
name: ProcessingPhase;
/** Current status of this phase */
status: 'pending' | 'in_progress' | 'completed' | 'error';
/** When phase started processing (ISO 8601 string) */
startedAt?: string;
/** When phase completed (ISO 8601 string) */
completedAt?: string;
/** Error message if phase failed */
error?: string;
}
/**
@@ -55,135 +47,135 @@ export interface PhaseProgress {
* Contains all outputs from the processing pipeline
*/
export interface ProcessingResults {
/** Extracted text from Instagram */
extractedText?: string;
/** Thumbnail URL or data URL */
thumbnail?: string | null;
/** Parsed recipe object */
recipe?: any;
/** Tandoor recipe ID */
tandoorRecipeId?: number;
/** Tandoor recipe URL (constructed from ID) */
tandoorUrl?: string;
/** Extracted text from Instagram */
extractedText?: string;
/** Thumbnail URL or data URL */
thumbnail?: string | null;
/** Parsed recipe object */
recipe?: any;
/** Tandoor recipe ID */
tandoorRecipeId?: number;
/** Tandoor recipe URL (constructed from ID) */
tandoorUrl?: string;
}
/**
* Queue item representing a single Instagram URL processing job
*/
export interface QueueItem {
/** Unique identifier (UUID) */
id: string;
/** Unique identifier (UUID) */
id: string;
/** Instagram URL to process */
url: string;
/** Instagram URL to process */
url: string;
/** Current status of the item */
status: QueueItemStatus;
/** Current status of the item */
status: QueueItemStatus;
// Phase tracking
/** Current processing phase (only set when status is in_progress) */
currentPhase?: ProcessingPhase;
// Phase tracking
/** Current processing phase (only set when status is in_progress) */
currentPhase?: ProcessingPhase;
/** Array of all phases with their progress status */
phases: PhaseProgress[];
/** Array of all phases with their progress status */
phases: PhaseProgress[];
// Timestamps
/** When item was added to queue (ISO 8601 string) */
enqueuedAt: string;
// Timestamps
/** When item was added to queue (ISO 8601 string) */
enqueuedAt: string;
/** Alias for enqueuedAt (frontend uses this) */
createdAt: string;
/** Alias for enqueuedAt (frontend uses this) */
createdAt: string;
/** When processing started (ISO 8601 string) */
startedAt?: string;
/** When processing started (ISO 8601 string) */
startedAt?: string;
/** When processing completed (ISO 8601 string) */
completedAt?: string;
/** When processing completed (ISO 8601 string) */
completedAt?: string;
/** Last update timestamp (ISO 8601 string) */
updatedAt?: string;
/** Last update timestamp (ISO 8601 string) */
updatedAt?: string;
// Results - wrapped in results object
/** Processing results container */
results?: ProcessingResults;
// Results - wrapped in results object
/** Processing results container */
results?: ProcessingResults;
// Legacy direct properties (kept for transition period)
/** @deprecated Use results.extractedText instead */
extractedText?: string;
// Legacy direct properties (kept for transition period)
/** @deprecated Use results.extractedText instead */
extractedText?: string;
/** @deprecated Use results.thumbnail instead */
thumbnail?: string | null;
/** @deprecated Use results.thumbnail instead */
thumbnail?: string | null;
/** @deprecated Use results.recipe instead */
recipe?: any;
/** @deprecated Use results.recipe instead */
recipe?: any;
/** @deprecated Use results.tandoorRecipeId instead */
tandoorRecipeId?: number;
/** @deprecated Use results.tandoorRecipeId instead */
tandoorRecipeId?: number;
// Progress tracking
/** User-facing log messages */
logs: string[];
// Progress tracking
/** User-facing log messages */
logs: string[];
/** All SSE progress events received */
progressEvents: ProgressEvent[];
/** All SSE progress events received */
progressEvents: ProgressEvent[];
// Error handling
/** Error details if processing failed */
error?: {
/** Phase where error occurred */
phase: ProcessingPhase;
/** Error message */
message: string;
/** Whether error is recoverable (can retry) */
recoverable: boolean;
/** When error occurred (ISO 8601 string) */
timestamp: string;
};
// Error handling
/** Error details if processing failed */
error?: {
/** Phase where error occurred */
phase: ProcessingPhase;
/** Error message */
message: string;
/** Whether error is recoverable (can retry) */
recoverable: boolean;
/** When error occurred (ISO 8601 string) */
timestamp: string;
};
// Retry tracking
/** Number of times this item has been retried */
retryCount: number;
// Retry tracking
/** Number of times this item has been retried */
retryCount: number;
/** Maximum number of retries allowed */
maxRetries: number;
/** Maximum number of retries allowed */
maxRetries: number;
}
/**
* Update notification sent to queue subscribers
*/
export interface QueueStatusUpdate {
/** Type of update */
type: 'status_change' | 'progress' | 'phase_complete';
/** Type of update */
type: 'status_change' | 'progress' | 'phase_complete';
/** ID of the item that was updated */
itemId: string;
/** ID of the item that was updated */
itemId: string;
/** New status of the item */
status: QueueItemStatus;
/** New status of the item */
status: QueueItemStatus;
/** When update occurred (ISO 8601 string) */
timestamp: string;
/** When update occurred (ISO 8601 string) */
timestamp: string;
/** URL of the item */
url?: string;
/** URL of the item */
url?: string;
// Phase information
/** Current phase (if status is in_progress) */
phase?: ProcessingPhase;
// Phase information
/** Current phase (if status is in_progress) */
phase?: ProcessingPhase;
/** Full phase progress array */
progress?: PhaseProgress[];
/** Full phase progress array */
progress?: PhaseProgress[];
// Results
/** Processing results object */
results?: ProcessingResults;
// Results
/** Processing results object */
results?: ProcessingResults;
// Error
/** Error information */
error?: any;
// Error
/** Error information */
error?: any;
/** Additional data related to the update (legacy) */
data?: any;
/** Additional data related to the update (legacy) */
data?: any;
}
/**

View File

@@ -73,7 +73,9 @@ async function renewInstagramAuth(): Promise<boolean> {
const authPath = resolveAuthPath();
if (!fs.existsSync(authPath)) {
console.warn('[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.');
console.warn(
'[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.'
);
return false;
}
@@ -115,7 +117,9 @@ async function renewInstagramAuth(): Promise<boolean> {
await context.storageState({ path: authPath });
state.lastRenewalTime = Date.now();
console.log(`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`);
console.log(
`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`
);
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
return true;
@@ -140,7 +144,9 @@ export async function startScheduler(): Promise<void> {
const config = getConfig();
if (!config.enabled) {
console.log('[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)');
console.log(
'[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)'
);
return;
}
@@ -151,7 +157,9 @@ export async function startScheduler(): Promise<void> {
const intervalMs = config.intervalMinutes * 60 * 1000;
console.log(`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`);
console.log(
`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`
);
// Schedule periodic renewals
state.intervalId = setInterval(async () => {

View File

@@ -15,38 +15,48 @@ export const TandoorRecipeSchema = z.object({
prep_time: z.string().optional(),
cook_time: z.string().optional(),
waiting_time: z.string().optional(),
steps: z.array(
z.object({
step: z.number(),
instruction: z.string(),
ingredients: z.array(
z.object({
food: z.object({
id: z.number(),
steps: z
.array(
z.object({
step: z.number(),
instruction: z.string(),
ingredients: z
.array(
z.object({
food: z.object({
id: z.number(),
name: z.string()
}),
unit: z
.object({
id: z.number(),
name: z.string()
})
.nullable(),
amount: z.number(),
note: z.string().optional()
})
)
.optional()
})
)
.optional(),
ingredients: z
.array(
z.object({
food: z.object({
name: z.string()
}),
unit: z
.object({
name: z.string()
}),
unit: z.object({
id: z.number(),
name: z.string()
}).nullable(),
amount: z.number(),
note: z.string().optional()
})
).optional()
})
).optional(),
ingredients: z.array(
z.object({
food: z.object({
name: z.string()
}),
unit: z.object({
name: z.string()
}).nullable(),
amount: z.number(),
note: z.string().optional()
})
).optional()
})
.nullable(),
amount: z.number(),
note: z.string().optional()
})
)
.optional()
});
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
@@ -104,11 +114,11 @@ interface TandoorRecipeDTO {
*/
async function fetchFromTandoor<T>(
url: string,
options: Partial<RequestInit> = { method: 'GET' },
options: Partial<RequestInit> = { method: 'GET' }
): Promise<{ ok: boolean; data?: T; error?: string }> {
const headers = new Headers({
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${tandoorConfig.token}`
});
@@ -120,10 +130,10 @@ async function fetchFromTandoor<T>(
});
}
console.debug(`Fetching from Tandoor: ${url}`, {
console.debug(`Fetching from Tandoor: ${url}`, {
method: options.method,
headers: Object.fromEntries(headers),
body: options.body
body: options.body
});
try {
const response = await fetch(`${tandoorConfig.serverUrl}${url}`, {
@@ -153,8 +163,6 @@ async function fetchFromTandoor<T>(
}
}
/**
* Partitions ingredients across steps by distributing them evenly
* When step association is unknown, this spreads ingredients proportionally
@@ -181,7 +189,10 @@ function partitionIngredientsAcrossSteps(
partitions[index % stepCount].push(ingredient);
});
console.debug(`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`, partitions);
console.debug(
`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`,
partitions
);
return partitions;
}
@@ -224,10 +235,7 @@ function parseAmount(amountStr: string): number | null {
*/
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
const stepCount = recipe.steps?.length || 1;
const ingredientPartitions = partitionIngredientsAcrossSteps(
recipe.ingredients || [],
stepCount
);
const ingredientPartitions = partitionIngredientsAcrossSteps(recipe.ingredients || [], stepCount);
const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => {
// Map ingredients, converting unparseable amounts to 1 q.b.
@@ -235,7 +243,9 @@ function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
const amount = parseAmount(ing.amount);
if (amount === null) {
console.debug(`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`);
console.debug(
`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`
);
return {
food: {
name: ing.item
@@ -297,14 +307,11 @@ export async function uploadRecipeWithIngredientsDTO(
console.debug('Uploading recipe with ingredients DTO:', recipeDTO);
// Call the API with the DTO
const recipeResult = await fetchFromTandoor<{ id: number }>(
`/api/recipe/`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipeDTO)
}
);
const recipeResult = await fetchFromTandoor<{ id: number }>(`/api/recipe/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipeDTO)
});
if (!recipeResult.ok || !recipeResult.data) {
console.error('Recipe creation failed:', recipeResult.error);
@@ -397,7 +404,9 @@ export async function uploadRecipeImage(
}
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`);
console.log(
`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`
);
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
let buffer: Buffer;
@@ -462,26 +471,27 @@ export async function uploadRecipeImage(
formData.append('image', file);
console.log('[Tandoor Upload] Uploading to Tandoor...');
const uploadResponse = await fetch(
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
// DO NOT set Content-Type - let fetch set it with boundary
},
body: formData
}
);
const uploadResponse = await fetch(`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`
// DO NOT set Content-Type - let fetch set it with boundary
},
body: formData
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
const responseHeaders = JSON.stringify(Object.fromEntries(uploadResponse.headers.entries()));
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
console.error(
`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`
);
console.error(`[Tandoor Upload] Response headers: ${responseHeaders}`);
console.error(`[Tandoor Upload] Response body: ${errorText.substring(0, 500)}`);
console.error(`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`);
console.error(
`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`
);
return {
success: false,

View File

@@ -27,24 +27,24 @@
* ```
*/
export function serializeError(error: unknown): string {
if (error instanceof Error) {
const errorObject: Record<string, any> = {
name: error.name,
message: error.message,
stack: error.stack
};
if (error instanceof Error) {
const errorObject: Record<string, any> = {
name: error.name,
message: error.message,
stack: error.stack
};
// Add custom properties from the error object
for (const key of Object.keys(error)) {
if (!(key in errorObject)) {
errorObject[key] = (error as any)[key];
}
}
// Add custom properties from the error object
for (const key of Object.keys(error)) {
if (!(key in errorObject)) {
errorObject[key] = (error as any)[key];
}
}
return JSON.stringify(errorObject, null, 2);
}
return JSON.stringify(errorObject, null, 2);
}
return JSON.stringify(error, null, 2);
return JSON.stringify(error, null, 2);
}
/**
@@ -64,19 +64,19 @@ export function serializeError(error: unknown): string {
* ```
*/
export function serializeObject(obj: unknown, maxDepth: number = 10): string {
const seen = new WeakSet();
const seen = new WeakSet();
const replacer = (key: string, value: any): any => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
};
const replacer = (key: string, value: any): any => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
};
return JSON.stringify(obj, replacer, 2);
return JSON.stringify(obj, replacer, 2);
}
/**
@@ -96,14 +96,14 @@ export function serializeObject(obj: unknown, maxDepth: number = 10): string {
* ```
*/
export function logError(prefix: string, error: unknown): void {
if (error instanceof Error) {
console.error(prefix, error.message);
if (error.stack) {
console.error('Stack:', error.stack);
}
} else {
console.error(prefix, serializeError(error));
}
if (error instanceof Error) {
console.error(prefix, error.message);
if (error.stack) {
console.error('Stack:', error.stack);
}
} else {
console.error(prefix, serializeError(error));
}
}
/**
@@ -120,5 +120,5 @@ export function logError(prefix: string, error: unknown): void {
* ```
*/
export function logObject(prefix: string, obj: unknown): void {
console.log(prefix, serializeObject(obj));
console.log(prefix, serializeObject(obj));
}

View File

@@ -14,48 +14,51 @@ import { queueManager } from '$lib/server/queue/QueueManager';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
export const GET = async () => {
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter(item => item.status === 'pending').length,
in_progress: allItems.filter(item => item.status === 'in_progress').length,
success: allItems.filter(item => item.status === 'success').length,
error: allItems.filter(item => item.status === 'error').length,
unhealthy: allItems.filter(item => item.status === 'unhealthy').length
};
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter((item) => item.status === 'pending').length,
in_progress: allItems.filter((item) => item.status === 'in_progress').length,
success: allItems.filter((item) => item.status === 'success').length,
error: allItems.filter((item) => item.status === 'error').length,
unhealthy: allItems.filter((item) => item.status === 'unhealthy').length
};
const stats = {
total: allItems.length
};
const stats = {
total: allItems.length
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json({
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
}, { status: 500 });
}
return json(
{
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
},
{ status: 500 }
);
}
};

View File

@@ -15,16 +15,22 @@ export async function GET() {
message: 'LLM service is accessible'
});
} else {
return json({
status: 'unhealthy',
message: 'LLM service is not accessible'
}, { status: 503 });
return json(
{
status: 'unhealthy',
message: 'LLM service is not accessible'
},
{ status: 503 }
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return json({
status: 'error',
message: errorMessage
}, { status: 500 });
return json(
{
status: 'error',
message: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -27,48 +27,38 @@ import type { RequestHandler } from './$types.js';
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { subscription, clientId } = await request.json();
try {
const { subscription, clientId } = await request.json();
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json({ error: 'Invalid subscription object' }, { status: 400 });
}
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
if (!clientId || typeof clientId !== 'string') {
return json({ error: 'Client ID is required' }, { status: 400 });
}
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json(
{ error: 'Failed to subscribe to notifications' },
{ status: 500 }
);
}
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json({ error: 'Failed to subscribe to notifications' }, { status: 500 });
}
};
/**
@@ -82,32 +72,25 @@ export const POST: RequestHandler = async ({ request }) => {
* }
*/
export const DELETE: RequestHandler = async ({ request }) => {
try {
const { clientId } = await request.json();
try {
const { clientId } = await request.json();
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
if (!clientId || typeof clientId !== 'string') {
return json({ error: 'Client ID is required' }, { status: 400 });
}
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json(
{ error: 'Failed to unsubscribe from notifications' },
{ status: 500 }
);
}
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json({ error: 'Failed to unsubscribe from notifications' }, { status: 500 });
}
};

View File

@@ -20,62 +20,60 @@ import type { RequestHandler } from './$types.js';
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { type } = await request.json();
try {
const { type } = await request.json();
if (!type || !['success', 'error', 'progress'].includes(type)) {
return json(
{ error: 'Invalid notification type. Must be: success, error, or progress' },
{ status: 400 }
);
}
if (!type || !['success', 'error', 'progress'].includes(type)) {
return json(
{ error: 'Invalid notification type. Must be: success, error, or progress' },
{ status: 400 }
);
}
const testItemId = 'test_' + Date.now();
const testItemId = 'test_' + Date.now();
// Create test payloads for each type
const payloads = {
success: {
type: 'success' as const,
itemId: testItemId,
body: 'Test recipe extraction completed successfully!',
recipeName: 'Test Recipe',
tag: `recipe-success-${testItemId}`,
requireInteraction: false
},
error: {
type: 'error' as const,
itemId: testItemId,
body: 'Test recipe extraction failed - this is a test error',
tag: `recipe-error-${testItemId}`,
requireInteraction: true
},
progress: {
type: 'progress' as const,
itemId: testItemId,
body: 'Test recipe extraction in progress: parsing phase',
tag: `recipe-progress-${testItemId}`,
requireInteraction: false
}
};
// Create test payloads for each type
const payloads = {
success: {
type: 'success' as const,
itemId: testItemId,
body: 'Test recipe extraction completed successfully!',
recipeName: 'Test Recipe',
tag: `recipe-success-${testItemId}`,
requireInteraction: false
},
error: {
type: 'error' as const,
itemId: testItemId,
body: 'Test recipe extraction failed - this is a test error',
tag: `recipe-error-${testItemId}`,
requireInteraction: true
},
progress: {
type: 'progress' as const,
itemId: testItemId,
body: 'Test recipe extraction in progress: parsing phase',
tag: `recipe-progress-${testItemId}`,
requireInteraction: false
}
};
const payload = payloads[type as keyof typeof payloads];
const payload = payloads[type as keyof typeof payloads];
await pushNotificationService.sendNotification(payload);
await pushNotificationService.sendNotification(payload);
console.log(`[NotificationTestAPI] Sent test ${type} notification`);
console.log(`[NotificationTestAPI] Sent test ${type} notification`);
return json({
success: true,
message: `Test ${type} notification sent`,
subscriberCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationTestAPI] Error sending test notification:',
error instanceof Error ? error.message : String(error));
return json(
{ error: 'Failed to send test notification' },
{ status: 500 }
);
}
return json({
success: true,
message: `Test ${type} notification sent`,
subscriberCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error(
'[NotificationTestAPI] Error sending test notification:',
error instanceof Error ? error.message : String(error)
);
return json({ error: 'Failed to send test notification' }, { status: 500 });
}
};

View File

@@ -21,26 +21,19 @@ import type { RequestHandler } from './$types.js';
* }
*/
export const GET: RequestHandler = async () => {
try {
const publicKey = pushNotificationService.getPublicVapidKey();
try {
const publicKey = pushNotificationService.getPublicVapidKey();
if (!publicKey) {
return json(
{ error: 'VAPID public key not configured' },
{ status: 503 }
);
}
if (!publicKey) {
return json({ error: 'VAPID public key not configured' }, { status: 503 });
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json({ error: 'Failed to get VAPID public key' }, { status: 500 });
}
};

View File

@@ -23,47 +23,46 @@ import type { RequestHandler } from './$types';
* Returns 400 for invalid URLs, 500 for server errors.
*/
export const POST: RequestHandler = async ({ request }) => {
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
throw new ValidationError('Invalid JSON in request body');
}
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
throw new ValidationError('Invalid JSON in request body');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
const { url } = body;
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (error) {
return handleApiError(error);
}
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (error) {
return handleApiError(error);
}
};
/**
@@ -77,73 +76,72 @@ export const POST: RequestHandler = async ({ request }) => {
* Returns: { items: QueueItem[], total: number, hasMore: boolean }
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const searchParams = url.searchParams;
try {
const searchParams = url.searchParams;
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('Limit cannot exceed 200');
}
limit = parsedLimit;
}
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('Limit cannot exceed 200');
}
limit = parsedLimit;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
throw new ValidationError('Offset must be a non-negative integer');
}
offset = parsedOffset;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
throw new ValidationError('Offset must be a non-negative integer');
}
offset = parsedOffset;
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
throw new ValidationError(
`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
);
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
throw new ValidationError(
`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
);
}
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Apply status filter
if (statusFilter) {
items = items.filter(item => item.status === statusFilter);
}
// Apply status filter
if (statusFilter) {
items = items.filter((item) => item.status === statusFilter);
}
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = (offset + limit) < items.length;
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = offset + limit < items.length;
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (error) {
return handleApiError(error);
}
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -19,33 +19,32 @@ import type { RequestHandler } from './$types';
* Returns 404 if item not found, 400 for invalid ID format.
*/
export const GET: RequestHandler = async ({ params }) => {
try {
const { id } = params;
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Get queue item
const queueItem = queueManager.get(id);
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(error);
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(error);
}
};
/**
@@ -56,42 +55,39 @@ export const GET: RequestHandler = async ({ params }) => {
* 409 if item is currently being processed.
*/
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { id } = params;
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Validate UUID format
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError(
'Cannot delete item that is currently being processed'
);
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError('Cannot delete item that is currently being processed');
}
// Remove the item
const success = queueManager.remove(id);
// Remove the item
const success = queueManager.remove(id);
return json({
success,
message: 'Queue item removed successfully'
});
} catch (error) {
return handleApiError(error);
}
return json({
success,
message: 'Queue item removed successfully'
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -21,50 +21,49 @@ import type { RequestHandler } from './$types';
* Returns 404 if item not found, 400 for invalid operations, 409 for wrong status.
*/
export const POST: RequestHandler = async ({ params }) => {
try {
const { id } = params;
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
);
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
);
}
// Retry the item
const retryResult = queueManager.retry(id);
// Retry the item
const retryResult = queueManager.retry(id);
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
throw new Error('Failed to retry queue item');
}
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
throw new Error('Failed to retry queue item');
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (error) {
return handleApiError(error);
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -24,196 +24,196 @@ import type { QueueStatusUpdate } from '$lib/server/queue/types';
* Connection is kept alive until client disconnects.
*/
export const GET: RequestHandler = async ({ url, request }) => {
const searchParams = url.searchParams;
const itemIdFilter = searchParams.get('id');
const statusFilter = searchParams.get('status');
const searchParams = url.searchParams;
const itemIdFilter = searchParams.get('id');
const statusFilter = searchParams.get('status');
// Validate status filter if provided
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// Validate status filter if provided
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// Validate item ID filter if provided
if (itemIdFilter) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(itemIdFilter)) {
return new Response('Invalid queue item ID format', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Validate item ID filter if provided
if (itemIdFilter) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(itemIdFilter)) {
return new Response('Invalid queue item ID format', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
console.log('[SSE] Cleaning up stream connection');
console.log('[SSE] Cleaning up stream connection');
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// Send current queue state as initial data
try {
const currentItems = queueManager.getAll();
let filteredItems = currentItems;
// Send current queue state as initial data
try {
const currentItems = queueManager.getAll();
let filteredItems = currentItems;
// Apply filters
if (itemIdFilter) {
filteredItems = currentItems.filter(item => item.id === itemIdFilter);
}
if (statusFilter) {
filteredItems = filteredItems.filter(item => item.status === statusFilter);
}
// Apply filters
if (itemIdFilter) {
filteredItems = currentItems.filter((item) => item.id === itemIdFilter);
}
if (statusFilter) {
filteredItems = filteredItems.filter((item) => item.status === statusFilter);
}
// Send initial state for each matching item
for (const item of filteredItems) {
if (isClosed) break; // Stop if stream was closed
// Send initial state for each matching item
for (const item of filteredItems) {
if (isClosed) break; // Stop if stream was closed
const update: QueueStatusUpdate = {
type: 'status_change',
itemId: item.id,
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
progress: item.phases,
results: item.results,
error: item.error
};
const update: QueueStatusUpdate = {
type: 'status_change',
itemId: item.id,
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
progress: item.phases,
results: item.results,
error: item.error
};
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// Apply filters
let shouldSend = true;
// Apply filters
let shouldSend = true;
if (itemIdFilter && update.itemId !== itemIdFilter) {
shouldSend = false;
}
if (itemIdFilter && update.itemId !== itemIdFilter) {
shouldSend = false;
}
if (statusFilter && update.status !== statusFilter) {
shouldSend = false;
}
if (statusFilter && update.status !== statusFilter) {
shouldSend = false;
}
if (shouldSend) {
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
safeEnqueue(controller, sseMessage);
}
});
if (shouldSend) {
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
safeEnqueue(controller, sseMessage);
}
});
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};

View File

@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
import { tandoorConfig } from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
return json({ ...tandoorConfig, token: '' });
}

View File

@@ -40,4 +40,4 @@ export const POST: RequestHandler = async ({ request }) => {
{ status: 500 }
);
}
}
};

View File

@@ -10,284 +10,281 @@ declare let self: ServiceWorkerGlobalScope;
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
const ASSETS = [
...build, // the app itself
...files // everything in `static`
...build, // the app itself
...files // everything in `static`
];
// Global error handlers (preserve existing)
self.addEventListener('error', (event) => {
console.error('[SW] Global error:', event.error);
console.error('[SW] Error details:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
});
console.error('[SW] Global error:', event.error);
console.error('[SW] Error details:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
});
});
self.addEventListener('unhandledrejection', (event) => {
console.error('[SW] Unhandled promise rejection:', event.reason);
event.preventDefault(); // Prevent default browser behavior
console.error('[SW] Unhandled promise rejection:', event.reason);
event.preventDefault(); // Prevent default browser behavior
});
console.log('[SW] Service worker script loading...');
// Install event - cache all assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
console.log('[SW] Installing service worker...');
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
console.log(`[SW] Cached ${ASSETS.length} assets`);
}
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
console.log(`[SW] Cached ${ASSETS.length} assets`);
}
event.waitUntil(addFilesToCache());
event.waitUntil(addFilesToCache());
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
console.log('[SW] Activating service worker...');
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
console.log('[SW] Deleting old cache:', key);
await caches.delete(key);
}
}
}
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
console.log('[SW] Deleting old cache:', key);
await caches.delete(key);
}
}
}
event.waitUntil(deleteOldCaches());
event.waitUntil(deleteOldCaches());
});
// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
if (event.request.method !== 'GET') return;
// ignore POST requests etc
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
event.respondWith(respond());
event.respondWith(respond());
});
// Push notification handling
self.addEventListener('push', (event) => {
console.log('[SW] Push event received:', event);
console.log('[SW] Push event received:', event);
if (!event.data) {
console.log('[SW] Push event but no data');
return;
}
if (!event.data) {
console.log('[SW] Push event but no data');
return;
}
let data;
try {
data = event.data.json();
} catch (e) {
console.error('[SW] Failed to parse push data:', e);
return;
}
let data;
try {
data = event.data.json();
} catch (e) {
console.error('[SW] Failed to parse push data:', e);
return;
}
console.log('[SW] Push data:', data);
console.log('[SW] Push data:', data);
const options: NotificationOptions = {
body: data.body || 'Recipe processing update',
icon: '/favicon.png',
badge: '/favicon.png',
data: data,
requireInteraction: data.requireInteraction || false,
silent: false,
tag: data.tag || 'recipe-update',
timestamp: Date.now(),
actions: []
};
const options: NotificationOptions = {
body: data.body || 'Recipe processing update',
icon: '/favicon.png',
badge: '/favicon.png',
data: data,
requireInteraction: data.requireInteraction || false,
silent: false,
tag: data.tag || 'recipe-update',
timestamp: Date.now(),
actions: []
};
// Add actions based on notification type
if (data.type === 'success' && data.itemId) {
options.actions = [
{
action: 'view',
title: 'View Recipe',
icon: '/favicon.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
];
} else if (data.type === 'error' && data.itemId) {
options.actions = [
{
action: 'retry',
title: 'Retry',
icon: '/favicon.png'
},
{
action: 'view',
title: 'View Details'
}
];
}
// Add actions based on notification type
if (data.type === 'success' && data.itemId) {
options.actions = [
{
action: 'view',
title: 'View Recipe',
icon: '/favicon.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
];
} else if (data.type === 'error' && data.itemId) {
options.actions = [
{
action: 'retry',
title: 'Retry',
icon: '/favicon.png'
},
{
action: 'view',
title: 'View Details'
}
];
}
const title = data.title || getNotificationTitle(data.type, data);
const title = data.title || getNotificationTitle(data.type, data);
event.waitUntil(
self.registration.showNotification(title, options)
);
event.waitUntil(self.registration.showNotification(title, options));
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click received:', event);
console.log('[SW] Notification click received:', event);
event.notification.close();
event.notification.close();
const data = event.notification.data;
const action = event.action;
const data = event.notification.data;
const action = event.action;
let url = '/';
let url = '/';
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientsList) => {
// Check if there's already a window/tab open
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
data: data
});
});
}
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
// Check if there's already a window/tab open
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
data: data
});
});
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Handle notification close
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event);
console.log('[SW] Notification closed:', event);
// Track notification dismissals if needed
const data = event.notification.data;
if (data?.analytics) {
// Could send analytics event here
console.log('[SW] Notification dismissed:', data);
}
// Track notification dismissals if needed
const data = event.notification.data;
if (data?.analytics) {
// Could send analytics event here
console.log('[SW] Notification dismissed:', data);
}
});
// Background sync for retry operations
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'retry-queue-item') {
event.waitUntil(handleRetrySync());
}
if (event.tag === 'retry-queue-item') {
event.waitUntil(handleRetrySync());
}
});
// Helper functions
function getNotificationTitle(type: string, data: any): string {
switch (type) {
case 'success':
return data.recipeName
? `✅ Recipe Ready: ${data.recipeName}`
: '✅ Recipe extraction complete';
case 'error':
return '❌ Recipe extraction failed';
case 'progress':
return `🔄 Processing recipe...`;
default:
return '📱 InstaRecipe Update';
}
switch (type) {
case 'success':
return data.recipeName
? `✅ Recipe Ready: ${data.recipeName}`
: '✅ Recipe extraction complete';
case 'error':
return '❌ Recipe extraction failed';
case 'progress':
return `🔄 Processing recipe...`;
default:
return '📱 InstaRecipe Update';
}
}
async function handleRetrySync() {
try {
// Get retry items from IndexedDB or localStorage if needed
console.log('[SW] Handling retry sync');
try {
// Get retry items from IndexedDB or localStorage if needed
console.log('[SW] Handling retry sync');
// This could implement background retry logic
// For now, we'll let the main app handle retries
return Promise.resolve();
} catch (error) {
console.error('[SW] Retry sync failed:', error);
throw error;
}
// This could implement background retry logic
// For now, we'll let the main app handle retries
return Promise.resolve();
} catch (error) {
console.error('[SW] Retry sync failed:', error);
throw error;
}
}
// Message handling for communication with main app
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
console.log('[SW] Message received:', event.data);
const { type, data } = event.data;
const { type, data } = event.data;
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: '1.0.0' });
break;
case 'QUEUE_RETRY':
// Queue a background sync for retry
self.registration.sync.register('retry-queue-item');
break;
default:
console.log('[SW] Unknown message type:', type);
}
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: '1.0.0' });
break;
case 'QUEUE_RETRY':
// Queue a background sync for retry
self.registration.sync.register('retry-queue-item');
break;
default:
console.log('[SW] Unknown message type:', type);
}
});

View File

@@ -4,77 +4,77 @@ import * as logger from '$lib/server/utils/logger';
import { ValidationError, NotFoundError, ConflictError } from '$lib/server/api/errors';
describe('errorHandler logging', () => {
let logErrorSpy: any;
let logErrorSpy: any;
beforeEach(() => {
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
beforeEach(() => {
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
test('should use logError for standard errors', () => {
const error = new Error('Test error');
test('should use logError for standard errors', () => {
const error = new Error('Test error');
handleApiError(error);
handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
});
test('should use logError for ValidationError', () => {
const error = new ValidationError('Invalid input');
test('should use logError for ValidationError', () => {
const error = new ValidationError('Invalid input');
const response = handleApiError(error);
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(400);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(400);
});
test('should use logError for NotFoundError', () => {
const error = new NotFoundError('Resource not found');
test('should use logError for NotFoundError', () => {
const error = new NotFoundError('Resource not found');
const response = handleApiError(error);
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(404);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(404);
});
test('should use logError for ConflictError', () => {
const error = new ConflictError('Resource conflict');
test('should use logError for ConflictError', () => {
const error = new ConflictError('Resource conflict');
const response = handleApiError(error);
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(409);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(409);
});
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_VALIDATION',
message: 'Invalid input',
details: { field: 'email', reason: 'invalid format' }
};
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_VALIDATION',
message: 'Invalid input',
details: { field: 'email', reason: 'invalid format' }
};
handleApiError(complexError);
handleApiError(complexError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', complexError);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', complexError);
});
test('should handle unknown error types', () => {
const unknownError = 'String error';
test('should handle unknown error types', () => {
const unknownError = 'String error';
handleApiError(unknownError);
handleApiError(unknownError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', unknownError);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', unknownError);
});
test('logs should not use console.error directly', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
test('logs should not use console.error directly', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('Test');
handleApiError(error);
const error = new Error('Test');
handleApiError(error);
// logError internally calls console.error, but handleApiError shouldn't call it directly
// We're checking that handleApiError uses logError, not console.error
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
// logError internally calls console.error, but handleApiError shouldn't call it directly
// We're checking that handleApiError uses logError, not console.error
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
consoleErrorSpy.mockRestore();
});
consoleErrorSpy.mockRestore();
});
});

View File

@@ -29,8 +29,8 @@ describe('extraction.ts logging', () => {
expect(calls.length).toBeGreaterThan(0);
// Verify at least one call has the expected format
const errorCall = calls.find((call: any[]) =>
call[0]?.match(/\[.*\]/) && call[1] !== undefined
const errorCall = calls.find(
(call: any[]) => call[0]?.match(/\[.*\]/) && call[1] !== undefined
);
expect(errorCall).toBeDefined();
expect(errorCall[0]).toMatch(/\[.*\]/); // Has prefix like [Retry], [Extractor], etc
@@ -49,14 +49,11 @@ describe('extraction.ts logging', () => {
}
// Check all console.warn and console.error calls
const allCalls = [
...consoleWarnSpy.mock.calls,
...consoleErrorSpy.mock.calls
];
const allCalls = [...consoleWarnSpy.mock.calls, ...consoleErrorSpy.mock.calls];
const errorCalls = allCalls
.map(call => call.join(' '))
.filter(msg => msg.includes('[object Object]'));
.map((call) => call.join(' '))
.filter((msg) => msg.includes('[object Object]'));
expect(errorCalls).toHaveLength(0);
});
@@ -78,9 +75,7 @@ describe('extraction.ts logging', () => {
// Call real logError
logger.logError('[Test] Real test', mockError);
const output = consoleErrorSpy.mock.calls
.map(call => call.join(' '))
.join(' ');
const output = consoleErrorSpy.mock.calls.map((call) => call.join(' ')).join(' ');
// Should not contain [object Object]
expect(output).not.toContain('[object Object]');

View File

@@ -8,19 +8,19 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
test('favicon.ico should exist', () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
expect(fs.existsSync(icoPath)).toBe(true);
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
expect(fs.existsSync(icoPath)).toBe(true);
});
test('favicon.ico should be 32x32', async () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.width).toBe(32);
expect(metadata.height).toBe(32);
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.width).toBe(32);
expect(metadata.height).toBe(32);
});
test('favicon.ico should be valid PNG format', async () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.format).toBe('png');
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.format).toBe('png');
});

View File

@@ -8,30 +8,30 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('PWA Icon Generation - favicon.png', () => {
const faviconPath = path.join(__dirname, '..', '..', 'static', 'favicon.png');
const faviconPath = path.join(__dirname, '..', '..', 'static', 'favicon.png');
test('favicon.png should exist', () => {
expect(fs.existsSync(faviconPath)).toBe(true);
});
test('favicon.png should exist', () => {
expect(fs.existsSync(faviconPath)).toBe(true);
});
test('favicon.png should have exact 192x192 dimensions', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.width).toBe(192);
expect(metadata.height).toBe(192);
});
test('favicon.png should have exact 192x192 dimensions', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.width).toBe(192);
expect(metadata.height).toBe(192);
});
test('favicon.png should be PNG format', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.format).toBe('png');
});
test('favicon.png should be PNG format', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.format).toBe('png');
});
test('favicon.png should be less than 100KB', () => {
const stats = fs.statSync(faviconPath);
expect(stats.size).toBeLessThan(100 * 1024);
});
test('favicon.png should be less than 100KB', () => {
const stats = fs.statSync(faviconPath);
expect(stats.size).toBeLessThan(100 * 1024);
});
test('favicon.png should have RGBA channels', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.channels).toBe(4); // RGBA
});
test('favicon.png should have RGBA channels', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.channels).toBe(4); // RGBA
});
});

View File

@@ -4,45 +4,45 @@ import fs from 'fs';
import path from 'path';
describe('Icon 512x512 Generation', () => {
const iconPath = path.resolve('static/icon-512.png');
const iconPath = path.resolve('static/icon-512.png');
it('should exist', () => {
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should exist', () => {
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have correct dimensions (512x512)', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.width).toBe(512);
expect(metadata.height).toBe(512);
});
it('should have correct dimensions (512x512)', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.width).toBe(512);
expect(metadata.height).toBe(512);
});
it('should be PNG format', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.format).toBe('png');
});
it('should be PNG format', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.format).toBe('png');
});
it('should have valid RGBA encoding', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.channels).toBeGreaterThanOrEqual(3); // At least RGB
});
it('should have valid RGBA encoding', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.channels).toBeGreaterThanOrEqual(3); // At least RGB
});
it('should be less than 200KB', () => {
const stats = fs.statSync(iconPath);
const sizeInKB = stats.size / 1024;
// Note: With current icon-source.png (672KB RGB), achieving both <200KB AND RGBA
// is not possible with lossless PNG compression. Trade-off: prioritize file size for web performance
expect(sizeInKB).toBeLessThan(300); // Relaxed from 200KB due to source image constraints
});
it('should be less than 200KB', () => {
const stats = fs.statSync(iconPath);
const sizeInKB = stats.size / 1024;
// Note: With current icon-source.png (672KB RGB), achieving both <200KB AND RGBA
// is not possible with lossless PNG compression. Trade-off: prioritize file size for web performance
expect(sizeInKB).toBeLessThan(300); // Relaxed from 200KB due to source image constraints
});
it('should have transparency support (alpha channel)', async () => {
const metadata = await sharp(iconPath).metadata();
// Note: Source image is RGB without alpha. When using palette optimization for file size,
// Sharp removes unused alpha channel. This is acceptable as transparency is not needed for this icon.
expect(metadata.channels).toBeGreaterThanOrEqual(3); // Accept RGB or RGBA
});
it('should have transparency support (alpha channel)', async () => {
const metadata = await sharp(iconPath).metadata();
// Note: Source image is RGB without alpha. When using palette optimization for file size,
// Sharp removes unused alpha channel. This is acceptable as transparency is not needed for this icon.
expect(metadata.channels).toBeGreaterThanOrEqual(3); // Accept RGB or RGBA
});
it('should not be corrupted', async () => {
// Try to read the image - will throw if corrupted
await expect(sharp(iconPath).metadata()).resolves.toBeDefined();
});
it('should not be corrupted', async () => {
// Try to read the image - will throw if corrupted
await expect(sharp(iconPath).metadata()).resolves.toBeDefined();
});
});

View File

@@ -31,7 +31,8 @@ describe('Instagram Caption Extraction E2E', () => {
const page = await context.newPage();
try {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
console.log('[DEBUG] Navigating to:', testUrl);
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
@@ -71,7 +72,6 @@ describe('Instagram Caption Extraction E2E', () => {
console.log(`\n[DEBUG] Shortcode appears ${htmlMatches} times in page HTML`);
expect(true).toBe(true);
} finally {
await page.close();
await context.close();
@@ -84,7 +84,8 @@ describe('Instagram Caption Extraction E2E', () => {
const page = await context.newPage();
try {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
console.log('[DEBUG] Navigating to:', testUrl);
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
@@ -96,7 +97,10 @@ describe('Instagram Caption Extraction E2E', () => {
// Try to find and click "more" button
console.log('[DEBUG] Looking for "more" button...');
const moreElements = await page.locator('span, div, button').filter({ hasText: /more/i }).all();
const moreElements = await page
.locator('span, div, button')
.filter({ hasText: /more/i })
.all();
console.log(`[DEBUG] Found ${moreElements.length} elements with "more"`);
for (let i = 0; i < Math.min(moreElements.length, 10); i++) {
@@ -126,7 +130,7 @@ describe('Instagram Caption Extraction E2E', () => {
const spanData = await page.evaluate(() => {
const spans = Array.from(document.querySelectorAll('span'));
return spans
.filter(s => (s.textContent || '').length > 30)
.filter((s) => (s.textContent || '').length > 30)
.map((s, idx) => ({
index: idx,
text: (s.textContent || '').substring(0, 200),
@@ -139,13 +143,14 @@ describe('Instagram Caption Extraction E2E', () => {
});
console.log('[DEBUG] Top spans by LENGTH after click attempt:');
spanData.slice(0, 5).forEach(span => {
console.log(` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`);
spanData.slice(0, 5).forEach((span) => {
console.log(
` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`
);
console.log(` Text: "${span.text}"`);
});
expect(true).toBe(true); // Dummy assertion
} finally {
await page.close();
await context.close();
@@ -156,7 +161,8 @@ describe('Instagram Caption Extraction E2E', () => {
// Instagram's current anti-scraping measures make full extraction difficult
// This test validates that we try all available methods
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const result = await extractTextAndThumbnail(testUrl);
@@ -191,7 +197,8 @@ describe('Instagram Caption Extraction E2E', () => {
}, 30000);
it('should handle extraction attempt and return truncated text gracefully', async () => {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const result = await extractTextAndThumbnail(testUrl);

View File

@@ -91,7 +91,8 @@ describe('extractFromDOM() with mocked og:description', () => {
it('should remove metadata prefix from og:description fallback', async () => {
// Exact fixture from context_compact.yaml
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const ogContent =
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const mockPage = createMockPage(ogContent);
@@ -104,7 +105,8 @@ describe('extractFromDOM() with mocked og:description', () => {
});
it('should remove opening quote after metadata prefix', async () => {
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const ogContent =
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const mockPage = createMockPage(ogContent);
@@ -168,7 +170,8 @@ describe('Integration: Full extraction flow', () => {
it('should extract, clean metadata prefix, remove quotes, and clean hashtags', async () => {
// Simulating what the browser's page.evaluate() would return AFTER cleaning metadata
// (the browser regex already strips the metadata prefix and quotes)
const browserCleanedContent = 'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
const browserCleanedContent =
'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
const mockPage = createMockPage(browserCleanedContent);
@@ -197,7 +200,8 @@ describe('Integration: Full extraction flow', () => {
it('should handle full real-world caption with multiline content', async () => {
// Browser has already cleaned metadata, only hashtags remain
const browserCleanedContent = 'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
const browserCleanedContent =
'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
const mockPage = createMockPage(browserCleanedContent);

View File

@@ -76,9 +76,6 @@ describe('llm.ts logging', () => {
await checkModelAvailability('test-model');
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Model availability check failed',
complexError
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Model availability check failed', complexError);
});
});

View File

@@ -2,157 +2,154 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { serializeError, serializeObject, logError, logObject } from '$lib/server/utils/logger';
describe('logger utilities', () => {
describe('serializeError', () => {
test('handles Error objects', () => {
const error = new Error('Test error message');
const result = serializeError(error);
describe('serializeError', () => {
test('handles Error objects', () => {
const error = new Error('Test error message');
const result = serializeError(error);
expect(result).toContain('Test error message');
expect(result).toContain('"name": "Error"');
expect(result).toContain('"message"');
});
expect(result).toContain('Test error message');
expect(result).toContain('"name": "Error"');
expect(result).toContain('"message"');
});
test('handles plain objects', () => {
const obj = { code: 404, message: 'Not found' };
const result = serializeError(obj);
test('handles plain objects', () => {
const obj = { code: 404, message: 'Not found' };
const result = serializeError(obj);
expect(result).toContain('"code": 404');
expect(result).toContain('"message": "Not found"');
});
expect(result).toContain('"code": 404');
expect(result).toContain('"message": "Not found"');
});
test('includes stack trace for Error objects', () => {
const error = new Error('Stack test');
const result = serializeError(error);
test('includes stack trace for Error objects', () => {
const error = new Error('Stack test');
const result = serializeError(error);
expect(result).toContain('"stack"');
});
expect(result).toContain('"stack"');
});
test('handles Error with custom properties', () => {
const error = new Error('Custom error') as any;
error.statusCode = 500;
error.details = { info: 'extra data' };
const result = serializeError(error);
test('handles Error with custom properties', () => {
const error = new Error('Custom error') as any;
error.statusCode = 500;
error.details = { info: 'extra data' };
const result = serializeError(error);
expect(result).toContain('"statusCode": 500');
expect(result).toContain('extra data');
});
});
expect(result).toContain('"statusCode": 500');
expect(result).toContain('extra data');
});
});
describe('serializeObject', () => {
test('handles circular references', () => {
const obj: any = { a: 1, b: 2 };
obj.self = obj;
describe('serializeObject', () => {
test('handles circular references', () => {
const obj: any = { a: 1, b: 2 };
obj.self = obj;
const result = serializeObject(obj);
expect(result).toContain('[Circular]');
expect(result).toContain('"a": 1');
});
const result = serializeObject(obj);
expect(result).toContain('[Circular]');
expect(result).toContain('"a": 1');
});
test('handles deeply nested objects', () => {
const obj = {
level1: {
level2: {
level3: {
value: 'deep'
}
}
}
};
test('handles deeply nested objects', () => {
const obj = {
level1: {
level2: {
level3: {
value: 'deep'
}
}
}
};
const result = serializeObject(obj);
expect(result).toContain('"value": "deep"');
});
const result = serializeObject(obj);
expect(result).toContain('"value": "deep"');
});
test('handles arrays', () => {
const obj = { items: [1, 2, 3] };
const result = serializeObject(obj);
test('handles arrays', () => {
const obj = { items: [1, 2, 3] };
const result = serializeObject(obj);
expect(result).toContain('"items"');
expect(result).toContain('[');
});
expect(result).toContain('"items"');
expect(result).toContain('[');
});
test('handles null and undefined', () => {
const obj = { a: null, b: undefined };
const result = serializeObject(obj);
test('handles null and undefined', () => {
const obj = { a: null, b: undefined };
const result = serializeObject(obj);
expect(result).toContain('"a": null');
});
});
expect(result).toContain('"a": null');
});
});
describe('logError', () => {
let consoleErrorSpy: any;
describe('logError', () => {
let consoleErrorSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
test('outputs to console.error', () => {
const error = new Error('Test');
test('outputs to console.error', () => {
const error = new Error('Test');
logError('[Test]', error);
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith('[Test]', 'Test');
});
expect(consoleErrorSpy).toHaveBeenCalledWith('[Test]', 'Test');
});
test('logs stack trace for Error objects', () => {
const error = new Error('Stack error');
test('logs stack trace for Error objects', () => {
const error = new Error('Stack error');
logError('[Test]', error);
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/Stack/),
expect.any(String)
);
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/Stack/),
expect.any(String)
);
});
test('handles non-Error objects', () => {
const obj = { code: 500, message: 'Server error' };
test('handles non-Error objects', () => {
const obj = { code: 500, message: 'Server error' };
logError('[Test]', obj);
logError('[Test]', obj);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"code": 500')
);
});
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"code": 500')
);
});
});
describe('logObject', () => {
let consoleLogSpy: any;
describe('logObject', () => {
let consoleLogSpy: any;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
test('outputs to console.log', () => {
const obj = { key: 'value' };
test('outputs to console.log', () => {
const obj = { key: 'value' };
logObject('[Test]', obj);
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"key": "value"')
);
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"key": "value"')
);
});
test('handles circular references', () => {
const obj: any = { a: 1 };
obj.self = obj;
test('handles circular references', () => {
const obj: any = { a: 1 };
obj.self = obj;
logObject('[Test]', obj);
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('[Circular]')
);
});
});
expect(consoleLogSpy).toHaveBeenCalledWith('[Test]', expect.stringContaining('[Circular]'));
});
});
});

View File

@@ -12,179 +12,181 @@ import { POST } from '../routes/api/notifications/test/+server';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
describe('POST /api/notifications/test', () => {
let sendNotificationSpy: any;
let getSubscriptionCountSpy: any;
let sendNotificationSpy: any;
let getSubscriptionCountSpy: any;
beforeEach(() => {
vi.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
// Spy on pushNotificationService methods
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
getSubscriptionCountSpy = vi.spyOn(pushNotificationService, 'getSubscriptionCount').mockReturnValue(2);
});
// Spy on pushNotificationService methods
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
getSubscriptionCountSpy = vi
.spyOn(pushNotificationService, 'getSubscriptionCount')
.mockReturnValue(2);
});
test('should validate notification type - reject invalid type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'invalid' })
});
test('should validate notification type - reject invalid type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'invalid' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
test('should validate notification type - reject missing type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
test('should validate notification type - reject missing type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
test('should send test success notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
test('should send test success notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('success');
expect(data.subscriberCount).toBe(2);
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('success');
expect(data.subscriberCount).toBe(2);
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
body: expect.stringContaining('Test recipe'),
recipeName: 'Test Recipe',
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-success-test_\d+$/),
requireInteraction: false
})
);
});
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
body: expect.stringContaining('Test recipe'),
recipeName: 'Test Recipe',
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-success-test_\d+$/),
requireInteraction: false
})
);
});
test('should send test error notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'error' })
});
test('should send test error notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'error' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('error');
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('error');
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
body: expect.stringContaining('test error'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-error-test_\d+$/),
requireInteraction: true
})
);
});
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
body: expect.stringContaining('test error'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-error-test_\d+$/),
requireInteraction: true
})
);
});
test('should send test progress notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'progress' })
});
test('should send test progress notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'progress' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('progress');
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('progress');
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'progress',
body: expect.stringContaining('parsing phase'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-progress-test_\d+$/),
requireInteraction: false
})
);
});
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'progress',
body: expect.stringContaining('parsing phase'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-progress-test_\d+$/),
requireInteraction: false
})
);
});
test('should return subscriber count in response', async () => {
getSubscriptionCountSpy.mockReturnValue(5);
test('should return subscriber count in response', async () => {
getSubscriptionCountSpy.mockReturnValue(5);
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(data.subscriberCount).toBe(5);
expect(getSubscriptionCountSpy).toHaveBeenCalled();
});
expect(data.subscriberCount).toBe(5);
expect(getSubscriptionCountSpy).toHaveBeenCalled();
});
test('should handle sendNotification errors', async () => {
sendNotificationSpy.mockRejectedValue(new Error('Push service error'));
test('should handle sendNotification errors', async () => {
sendNotificationSpy.mockRejectedValue(new Error('Push service error'));
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const response = await POST({ request } as any);
const data = await response.json();
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toContain('Failed to send test notification');
});
expect(response.status).toBe(500);
expect(data.error).toContain('Failed to send test notification');
});
test('should generate unique itemId for each request', async () => {
const request1 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
test('should generate unique itemId for each request', async () => {
const request1 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const request2 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const request2 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
await POST({ request: request1 } as any);
const call1 = sendNotificationSpy.mock.calls[0][0];
await POST({ request: request1 } as any);
const call1 = sendNotificationSpy.mock.calls[0][0];
// Wait a bit to ensure different timestamp
await new Promise(resolve => setTimeout(resolve, 2));
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 2));
await POST({ request: request2 } as any);
const call2 = sendNotificationSpy.mock.calls[1][0];
await POST({ request: request2 } as any);
const call2 = sendNotificationSpy.mock.calls[1][0];
expect(call1.itemId).not.toBe(call2.itemId);
expect(call1.tag).not.toBe(call2.tag);
});
expect(call1.itemId).not.toBe(call2.itemId);
expect(call1.tag).not.toBe(call2.tag);
});
});

View File

@@ -47,10 +47,7 @@ describe('parser.ts logging', () => {
// Expected to throw
}
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Recipe detection error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe detection error', expect.any(Error));
});
test('parseRecipe should use logError on failure', async () => {
@@ -60,10 +57,7 @@ describe('parser.ts logging', () => {
// Expected to throw
}
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Recipe parsing error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe parsing error', expect.any(Error));
});
test('should not log stack trace separately', async () => {
@@ -73,8 +67,9 @@ describe('parser.ts logging', () => {
// Expected to throw
}
const stackCalls = consoleErrorSpy.mock.calls
.filter((call: any) => call[0]?.includes('Stack trace'));
const stackCalls = consoleErrorSpy.mock.calls.filter((call: any) =>
call[0]?.includes('Stack trace')
);
expect(stackCalls).toHaveLength(0);
});

View File

@@ -4,190 +4,189 @@ import webpush from 'web-push';
// Mock web-push module BEFORE importing the service
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn()
}
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn()
}
}));
// Import service AFTER mocking
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
describe('PushNotificationService web-push integration', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear all subscriptions before each test
pushNotificationService.clearAllSubscriptions();
});
beforeEach(() => {
vi.clearAllMocks();
// Clear all subscriptions before each test
pushNotificationService.clearAllSubscriptions();
});
test('should have VAPID public key configured', () => {
// Verify the service has a public VAPID key available
const publicKey = pushNotificationService.getPublicVapidKey();
expect(publicKey).toBeTruthy();
expect(typeof publicKey).toBe('string');
expect(publicKey!.length).toBeGreaterThan(0);
});
test('should have VAPID public key configured', () => {
// Verify the service has a public VAPID key available
const publicKey = pushNotificationService.getPublicVapidKey();
expect(publicKey).toBeTruthy();
expect(typeof publicKey).toBe('string');
expect(publicKey!.length).toBeGreaterThan(0);
});
test('should send notification with web-push', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
test('should send notification with web-push', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-1', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-123',
body: 'Test notification'
});
await pushNotificationService.subscribe('client-1', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-123',
body: 'Test notification'
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: mockSubscription.endpoint,
keys: mockSubscription.keys
}),
expect.any(String),
expect.objectContaining({
TTL: 60 * 60 * 24
})
);
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: mockSubscription.endpoint,
keys: mockSubscription.keys
}),
expect.any(String),
expect.objectContaining({
TTL: 60 * 60 * 24
})
);
});
test('should handle subscription expiration (410)', async () => {
const mockError: any = new Error('Gone');
mockError.statusCode = 410;
test('should handle subscription expiration (410)', async () => {
const mockError: any = new Error('Gone');
mockError.statusCode = 410;
vi.mocked(webpush.sendNotification).mockRejectedValue(mockError);
vi.mocked(webpush.sendNotification).mockRejectedValue(mockError);
const mockSubscription = {
endpoint: 'https://push.example.com/expired',
keys: { p256dh: 'test', auth: 'test' }
};
const mockSubscription = {
endpoint: 'https://push.example.com/expired',
keys: { p256dh: 'test', auth: 'test' }
};
await pushNotificationService.subscribe('client-1', mockSubscription);
await pushNotificationService.subscribe('client-1', mockSubscription);
// Verify subscription exists before sending
expect(pushNotificationService.getSubscriptionCount()).toBe(1);
// Verify subscription exists before sending
expect(pushNotificationService.getSubscriptionCount()).toBe(1);
// sendNotification catches errors internally and removes invalid subscriptions
// It doesn't throw, so we just await it
await pushNotificationService.sendNotification({
type: 'error',
itemId: 'test',
body: 'Test'
});
// sendNotification catches errors internally and removes invalid subscriptions
// It doesn't throw, so we just await it
await pushNotificationService.sendNotification({
type: 'error',
itemId: 'test',
body: 'Test'
});
// Verify the subscription was removed due to 410 error
expect(pushNotificationService.getSubscriptionCount()).toBe(0);
});
// Verify the subscription was removed due to 410 error
expect(pushNotificationService.getSubscriptionCount()).toBe(0);
});
test('should send notification with TTL of 24 hours', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-ttl',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
test('should send notification with TTL of 24 hours', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-ttl',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-2', mockSubscription);
await pushNotificationService.sendNotification({
type: 'progress',
itemId: 'test-456',
body: 'Progress update'
});
await pushNotificationService.subscribe('client-2', mockSubscription);
await pushNotificationService.sendNotification({
type: 'progress',
itemId: 'test-456',
body: 'Progress update'
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.any(Object),
expect.any(String),
{ TTL: 60 * 60 * 24 }
);
});
expect(webpush.sendNotification).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
TTL: 60 * 60 * 24
});
});
test('should serialize notification data as JSON', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-json',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
test('should serialize notification data as JSON', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-json',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
const testPayload = {
type: 'success' as const,
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
};
const testPayload = {
type: 'success' as const,
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
};
await pushNotificationService.subscribe('client-3', mockSubscription);
await pushNotificationService.sendNotification(testPayload);
await pushNotificationService.subscribe('client-3', mockSubscription);
await pushNotificationService.sendNotification(testPayload);
const sendCallArgs = vi.mocked(webpush.sendNotification).mock.calls[0];
const sentPayload = sendCallArgs[1];
const sendCallArgs = vi.mocked(webpush.sendNotification).mock.calls[0];
const sentPayload = sendCallArgs[1];
// Verify the payload is stringified JSON
expect(typeof sentPayload).toBe('string');
const parsedPayload = JSON.parse(sentPayload);
expect(parsedPayload).toMatchObject({
type: 'success',
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
});
});
// Verify the payload is stringified JSON
expect(typeof sentPayload).toBe('string');
const parsedPayload = JSON.parse(sentPayload);
expect(parsedPayload).toMatchObject({
type: 'success',
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
});
});
test('should handle multiple subscriptions', async () => {
const mockSubscription1 = {
endpoint: 'https://push.example.com/client1',
keys: { p256dh: 'key1', auth: 'auth1' }
};
const mockSubscription2 = {
endpoint: 'https://push.example.com/client2',
keys: { p256dh: 'key2', auth: 'auth2' }
};
test('should handle multiple subscriptions', async () => {
const mockSubscription1 = {
endpoint: 'https://push.example.com/client1',
keys: { p256dh: 'key1', auth: 'auth1' }
};
const mockSubscription2 = {
endpoint: 'https://push.example.com/client2',
keys: { p256dh: 'key2', auth: 'auth2' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-1', mockSubscription1);
await pushNotificationService.subscribe('client-2', mockSubscription2);
await pushNotificationService.subscribe('client-1', mockSubscription1);
await pushNotificationService.subscribe('client-2', mockSubscription2);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-multi',
body: 'Multi-subscriber test'
});
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-multi',
body: 'Multi-subscriber test'
});
// Should have sent to both subscribers
expect(webpush.sendNotification).toHaveBeenCalledTimes(2);
});
// Should have sent to both subscribers
expect(webpush.sendNotification).toHaveBeenCalledTimes(2);
});
test('should log endpoint prefix only (privacy)', async () => {
const consoleSpy = vi.spyOn(console, 'log');
test('should log endpoint prefix only (privacy)', async () => {
const consoleSpy = vi.spyOn(console, 'log');
const longEndpoint = 'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
const mockSubscription = {
endpoint: longEndpoint,
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
const longEndpoint =
'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
const mockSubscription = {
endpoint: longEndpoint,
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-privacy', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-privacy',
body: 'Privacy test'
});
await pushNotificationService.subscribe('client-privacy', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-privacy',
body: 'Privacy test'
});
// Find the log call with endpoint
const endpointLogCall = consoleSpy.mock.calls.find(
call => typeof call[0] === 'string' && call[0].includes('Sent notification to')
);
// Find the log call with endpoint
const endpointLogCall = consoleSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && call[0].includes('Sent notification to')
);
expect(endpointLogCall).toBeTruthy();
// Should log only first 50 chars + ellipsis, not the full endpoint
expect(endpointLogCall![0]).toContain(longEndpoint.substring(0, 50));
expect(endpointLogCall![0]).not.toContain('secret-tokens');
});
expect(endpointLogCall).toBeTruthy();
// Should log only first 50 chars + ellipsis, not the full endpoint
expect(endpointLogCall![0]).toContain(longEndpoint.substring(0, 50));
expect(endpointLogCall![0]).not.toContain('secret-tokens');
});
});

View File

@@ -15,190 +15,192 @@
import { test, expect, type BrowserContext } from '@playwright/test';
test.describe('Push Notifications E2E', () => {
let context: BrowserContext;
let context: BrowserContext;
test.beforeEach(async ({ browser }) => {
// Create new context with notification permissions granted
context = await browser.newContext();
await context.grantPermissions(['notifications']);
});
test.beforeEach(async ({ browser }) => {
// Create new context with notification permissions granted
context = await browser.newContext();
await context.grantPermissions(['notifications']);
});
test.afterEach(async () => {
await context?.close();
});
test.afterEach(async () => {
await context?.close();
});
test('should subscribe to push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should subscribe to push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker to be registered
await page.waitForFunction(() => 'serviceWorker' in navigator && 'PushManager' in window);
// Wait for service worker to be registered
await page.waitForFunction(() => 'serviceWorker' in navigator && 'PushManager' in window);
// Find the notification toggle button
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await expect(toggleButton).toBeVisible();
// Find the notification toggle button
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await expect(toggleButton).toBeVisible();
// Click to enable notifications
await toggleButton.click();
// Click to enable notifications
await toggleButton.click();
// Wait for subscription to complete
await page.waitForTimeout(2000);
// Wait for subscription to complete
await page.waitForTimeout(2000);
// Verify subscription was created in browser
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
return sub ? {
endpoint: sub.endpoint,
hasKeys: !!(sub as any).keys
} : null;
});
// Verify subscription was created in browser
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
return sub
? {
endpoint: sub.endpoint,
hasKeys: !!(sub as any).keys
}
: null;
});
expect(subscription).not.toBeNull();
expect(subscription?.endpoint).toBeTruthy();
expect(subscription?.endpoint).toContain('https://');
expect(subscription?.hasKeys).toBe(true);
expect(subscription).not.toBeNull();
expect(subscription?.endpoint).toBeTruthy();
expect(subscription?.endpoint).toContain('https://');
expect(subscription?.hasKeys).toBe(true);
// Verify button text changed to "Disable Notifications"
await expect(toggleButton).toHaveText(/disable notifications/i);
// Verify button text changed to "Disable Notifications"
await expect(toggleButton).toHaveText(/disable notifications/i);
await page.close();
});
await page.close();
});
test('should show test notification buttons when subscribed', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should show test notification buttons when subscribed', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify test buttons are visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
const testErrorButton = page.getByRole('button', { name: /test error/i });
const testProgressButton = page.getByRole('button', { name: /test progress/i });
// Verify test buttons are visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
const testErrorButton = page.getByRole('button', { name: /test error/i });
const testProgressButton = page.getByRole('button', { name: /test progress/i });
await expect(testSuccessButton).toBeVisible();
await expect(testErrorButton).toBeVisible();
await expect(testProgressButton).toBeVisible();
await expect(testSuccessButton).toBeVisible();
await expect(testErrorButton).toBeVisible();
await expect(testProgressButton).toBeVisible();
await page.close();
});
await page.close();
});
test('should send test notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should send test notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Mock the test notification API response
await page.route('/api/notifications/test', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, subscriberCount: 1 })
});
});
// Mock the test notification API response
await page.route('/api/notifications/test', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, subscriberCount: 1 })
});
});
// Click test success button
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await testSuccessButton.click();
// Click test success button
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await testSuccessButton.click();
// Wait for and verify success message
const successMessage = page.getByText(/✓ test success notification sent/i);
await expect(successMessage).toBeVisible({ timeout: 5000 });
// Wait for and verify success message
const successMessage = page.getByText(/✓ test success notification sent/i);
await expect(successMessage).toBeVisible({ timeout: 5000 });
// Verify message contains subscriber count
await expect(successMessage).toContainText('1 subscriber');
// Verify message contains subscriber count
await expect(successMessage).toContainText('1 subscriber');
// Wait for auto-dismiss
await expect(successMessage).not.toBeVisible({ timeout: 4000 });
// Wait for auto-dismiss
await expect(successMessage).not.toBeVisible({ timeout: 4000 });
await page.close();
});
await page.close();
});
test('should unsubscribe from push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should unsubscribe from push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// First subscribe
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// First subscribe
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify subscribed
await expect(toggleButton).toHaveText(/disable notifications/i);
// Verify subscribed
await expect(toggleButton).toHaveText(/disable notifications/i);
// Now unsubscribe
await toggleButton.click();
await page.waitForTimeout(2000);
// Now unsubscribe
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify subscription was removed
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
});
// Verify subscription was removed
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
});
expect(subscription).toBeNull();
expect(subscription).toBeNull();
// Verify button text changed back
await expect(toggleButton).toHaveText(/enable notifications/i);
// Verify button text changed back
await expect(toggleButton).toHaveText(/enable notifications/i);
// Verify test buttons are no longer visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await expect(testSuccessButton).not.toBeVisible();
// Verify test buttons are no longer visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await expect(testSuccessButton).not.toBeVisible();
await page.close();
});
await page.close();
});
test('should persist clientId in localStorage', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should persist clientId in localStorage', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Enable notifications
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Enable notifications
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify clientId is stored in localStorage
const clientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
// Verify clientId is stored in localStorage
const clientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
expect(clientId).toBeTruthy();
expect(clientId).toMatch(/^client_[a-f0-9-]+$/);
expect(clientId).toBeTruthy();
expect(clientId).toMatch(/^client_[a-f0-9-]+$/);
// Reload page and verify clientId persists
await page.reload();
await page.waitForLoadState('networkidle');
// Reload page and verify clientId persists
await page.reload();
await page.waitForLoadState('networkidle');
const persistedClientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
const persistedClientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
expect(persistedClientId).toBe(clientId);
expect(persistedClientId).toBe(clientId);
await page.close();
});
await page.close();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -11,98 +11,89 @@ import * as logger from '$lib/server/utils/logger';
import type { QueueUpdateCallback } from '$lib/server/queue/types';
describe('QueueManager logging', () => {
let manager: QueueManager;
let logErrorSpy: any;
let manager: QueueManager;
let logErrorSpy: any;
beforeEach(() => {
manager = new QueueManager();
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
beforeEach(() => {
manager = new QueueManager();
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
test('should use logError when subscriber throws error', () => {
const failingCallback: QueueUpdateCallback = () => {
throw new Error('Subscriber failed');
};
test('should use logError when subscriber throws error', () => {
const failingCallback: QueueUpdateCallback = () => {
throw new Error('Subscriber failed');
};
manager.subscribe(failingCallback);
manager.subscribe(failingCallback);
// Enqueue an item (this will notify subscribers)
manager.enqueue('https://instagram.com/p/test123');
// Enqueue an item (this will notify subscribers)
manager.enqueue('https://instagram.com/p/test123');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.any(Error)
);
});
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', expect.any(Error));
});
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_SUBSCRIBER',
message: 'Callback failed',
details: { reason: 'Network timeout' }
};
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_SUBSCRIBER',
message: 'Callback failed',
details: { reason: 'Network timeout' }
};
const failingCallback: QueueUpdateCallback = () => {
throw complexError;
};
const failingCallback: QueueUpdateCallback = () => {
throw complexError;
};
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/test456');
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/test456');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
complexError
);
});
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', complexError);
});
test('should not prevent other subscribers from being notified on error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failingCallback: QueueUpdateCallback = () => {
throw new Error('First subscriber fails');
};
const successCallback = vi.fn();
test('should not prevent other subscribers from being notified on error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failingCallback: QueueUpdateCallback = () => {
throw new Error('First subscriber fails');
};
const successCallback = vi.fn();
manager.subscribe(failingCallback);
manager.subscribe(successCallback);
manager.subscribe(failingCallback);
manager.subscribe(successCallback);
manager.enqueue('https://instagram.com/p/test789');
manager.enqueue('https://instagram.com/p/test789');
// Error should be logged via logError
expect(logErrorSpy).toHaveBeenCalled();
// Error should be logged via logError
expect(logErrorSpy).toHaveBeenCalled();
// Second subscriber should still be called
expect(successCallback).toHaveBeenCalled();
// Second subscriber should still be called
expect(successCallback).toHaveBeenCalled();
// Should not contain [object Object] in console output
const errorMessages = consoleErrorSpy.mock.calls
.map(call => call.join(' '));
// Should not contain [object Object] in console output
const errorMessages = consoleErrorSpy.mock.calls.map((call) => call.join(' '));
const hasObjectObject = errorMessages.some(msg =>
msg.includes('[object Object]')
);
const hasObjectObject = errorMessages.some((msg) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
});
expect(hasObjectObject).toBe(false);
});
test('should handle Error instances with custom properties', () => {
const customError: any = new Error('Custom error');
customError.statusCode = 500;
customError.details = { field: 'url', issue: 'invalid' };
test('should handle Error instances with custom properties', () => {
const customError: any = new Error('Custom error');
customError.statusCode = 500;
customError.details = { field: 'url', issue: 'invalid' };
const failingCallback: QueueUpdateCallback = () => {
throw customError;
};
const failingCallback: QueueUpdateCallback = () => {
throw customError;
};
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/custom');
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/custom');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.objectContaining({
message: 'Custom error',
statusCode: 500,
details: { field: 'url', issue: 'invalid' }
})
);
});
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.objectContaining({
message: 'Custom error',
statusCode: 500,
details: { field: 'url', issue: 'invalid' }
})
);
});
});

View File

@@ -8,349 +8,349 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { QueueManager } from '$lib/server/queue/QueueManager';
describe('QueueManager', () => {
let queueManager: QueueManager;
beforeEach(() => {
// Create fresh instance for each test
queueManager = new QueueManager();
});
let queueManager: QueueManager;
beforeEach(() => {
// Create fresh instance for each test
queueManager = new QueueManager();
});
describe('enqueue', () => {
it('should enqueue items with unique IDs', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
expect(item1.id).toBeTruthy();
expect(item2.id).toBeTruthy();
expect(item1.id).not.toBe(item2.id);
});
it('should create items with pending status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
expect(item.enqueuedAt).toBeTruthy();
expect(item.logs).toEqual([]);
expect(item.progressEvents).toEqual([]);
expect(item.retryCount).toBe(0);
expect(item.maxRetries).toBe(3);
});
it('should notify subscribers when enqueueing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
status: 'pending'
})
);
});
});
describe('dequeue', () => {
it('should dequeue oldest pending item first (FIFO)', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
const dequeued1 = queueManager.dequeue();
expect(dequeued1?.id).toBe(item1.id);
const dequeued2 = queueManager.dequeue();
expect(dequeued2?.id).toBe(item2.id);
});
it('should return null when queue is empty', () => {
const item = queueManager.dequeue();
expect(item).toBeNull();
});
it('should mark dequeued item as in_progress', () => {
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
const dequeuedItem = queueManager.dequeue();
expect(dequeuedItem?.status).toBe('in_progress');
expect(dequeuedItem?.currentPhase).toBe('extraction');
expect(dequeuedItem?.startedAt).toBeTruthy();
});
it('should skip non-pending items', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
// Dequeue first item
queueManager.dequeue();
// Second item should be next
const dequeued = queueManager.dequeue();
expect(dequeued?.id).toBe(item2.id);
});
});
describe('updateStatus', () => {
it('should update item status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('in_progress');
expect(updated?.currentPhase).toBe('parsing');
});
it('should set completedAt for terminal statuses', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success');
const updated = queueManager.get(item.id);
expect(updated?.completedAt).toBeTruthy();
});
it('should merge additional data into item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success', {
recipe: { name: 'Test Recipe' },
tandoorRecipeId: 123
});
const updated = queueManager.get(item.id);
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
expect(updated?.tandoorRecipeId).toBe(123);
});
it('should handle error data', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const errorData = {
error: {
phase: 'extraction' as const,
message: 'Failed to load page',
recoverable: true,
timestamp: new Date().toISOString()
}
};
queueManager.updateStatus(item.id, 'unhealthy', errorData);
const updated = queueManager.get(item.id);
expect(updated?.error).toEqual(errorData.error);
});
});
describe('addProgressEvent', () => {
it('should add progress events to item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const event = {
type: 'status',
message: 'Extracting...',
timestamp: new Date().toISOString()
};
queueManager.addProgressEvent(item.id, event);
describe('enqueue', () => {
it('should enqueue items with unique IDs', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
expect(item1.id).toBeTruthy();
expect(item2.id).toBeTruthy();
expect(item1.id).not.toBe(item2.id);
});
it('should create items with pending status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
expect(item.enqueuedAt).toBeTruthy();
expect(item.logs).toEqual([]);
expect(item.progressEvents).toEqual([]);
expect(item.retryCount).toBe(0);
expect(item.maxRetries).toBe(3);
});
it('should notify subscribers when enqueueing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
status: 'pending'
})
);
});
});
describe('dequeue', () => {
it('should dequeue oldest pending item first (FIFO)', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
const dequeued1 = queueManager.dequeue();
expect(dequeued1?.id).toBe(item1.id);
const dequeued2 = queueManager.dequeue();
expect(dequeued2?.id).toBe(item2.id);
});
it('should return null when queue is empty', () => {
const item = queueManager.dequeue();
expect(item).toBeNull();
});
it('should mark dequeued item as in_progress', () => {
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
const dequeuedItem = queueManager.dequeue();
expect(dequeuedItem?.status).toBe('in_progress');
expect(dequeuedItem?.currentPhase).toBe('extraction');
expect(dequeuedItem?.startedAt).toBeTruthy();
});
it('should skip non-pending items', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
// Dequeue first item
queueManager.dequeue();
// Second item should be next
const dequeued = queueManager.dequeue();
expect(dequeued?.id).toBe(item2.id);
});
});
describe('updateStatus', () => {
it('should update item status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('in_progress');
expect(updated?.currentPhase).toBe('parsing');
});
it('should set completedAt for terminal statuses', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success');
const updated = queueManager.get(item.id);
expect(updated?.completedAt).toBeTruthy();
});
it('should merge additional data into item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success', {
recipe: { name: 'Test Recipe' },
tandoorRecipeId: 123
});
const updated = queueManager.get(item.id);
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
expect(updated?.tandoorRecipeId).toBe(123);
});
it('should handle error data', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const errorData = {
error: {
phase: 'extraction' as const,
message: 'Failed to load page',
recoverable: true,
timestamp: new Date().toISOString()
}
};
queueManager.updateStatus(item.id, 'unhealthy', errorData);
const updated = queueManager.get(item.id);
expect(updated?.error).toEqual(errorData.error);
});
});
describe('addProgressEvent', () => {
it('should add progress events to item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const event = {
type: 'status',
message: 'Extracting...',
timestamp: new Date().toISOString()
};
queueManager.addProgressEvent(item.id, event);
const updated = queueManager.get(item.id);
expect(updated?.progressEvents).toHaveLength(1);
expect(updated?.progressEvents[0]).toEqual(event);
});
it('should add event message to logs', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const updated = queueManager.get(item.id);
expect(updated?.progressEvents).toHaveLength(1);
expect(updated?.progressEvents[0]).toEqual(event);
});
it('should add event message to logs', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Test message',
timestamp: new Date().toISOString()
});
const updated = queueManager.get(item.id);
expect(updated?.logs).toContain('Test message');
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Test message',
timestamp: new Date().toISOString()
});
const updated = queueManager.get(item.id);
expect(updated?.logs).toContain('Test message');
});
it('should notify subscribers with event data', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear(); // Clear enqueue notification
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
queueManager.addProgressEvent(item.id, event);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { event }
})
);
});
});
describe('remove', () => {
it('should remove items by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const removed = queueManager.remove(item.id);
expect(removed).toBe(true);
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should notify subscribers with event data', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear(); // Clear enqueue notification
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
queueManager.addProgressEvent(item.id, event);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { event }
})
);
});
});
describe('remove', () => {
it('should remove items by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const removed = queueManager.remove(item.id);
expect(removed).toBe(true);
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should return false for non-existent items', () => {
const removed = queueManager.remove('non-existent-id');
expect(removed).toBe(false);
});
it('should notify subscribers when removing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear();
queueManager.remove(item.id);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { removed: true }
})
);
});
});
describe('retry', () => {
it('should retry failed items', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
const retried = queueManager.retry(item.id);
expect(retried).toBe(true);
it('should return false for non-existent items', () => {
const removed = queueManager.remove('non-existent-id');
expect(removed).toBe(false);
});
it('should notify subscribers when removing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear();
queueManager.remove(item.id);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { removed: true }
})
);
});
});
describe('retry', () => {
it('should retry failed items', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
const retried = queueManager.retry(item.id);
expect(retried).toBe(true);
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('pending');
expect(updated?.retryCount).toBe(1);
expect(updated?.error).toBeUndefined();
expect(updated?.currentPhase).toBeUndefined();
});
it('should not retry items in progress', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress');
const retried = queueManager.retry(item.id);
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('pending');
expect(updated?.retryCount).toBe(1);
expect(updated?.error).toBeUndefined();
expect(updated?.currentPhase).toBeUndefined();
});
it('should not retry items in progress', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress');
const retried = queueManager.retry(item.id);
expect(retried).toBe(false);
expect(queueManager.get(item.id)?.status).toBe('in_progress');
});
expect(retried).toBe(false);
expect(queueManager.get(item.id)?.status).toBe('in_progress');
});
it('should increment retry count', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
it('should increment retry count', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
queueManager.retry(item.id);
queueManager.retry(item.id);
expect(queueManager.get(item.id)?.retryCount).toBe(2);
});
});
describe('getAll', () => {
it('should return all queue items', () => {
queueManager.enqueue('https://instagram.com/p/test1');
queueManager.enqueue('https://instagram.com/p/test2');
queueManager.enqueue('https://instagram.com/p/test3');
queueManager.retry(item.id);
queueManager.retry(item.id);
expect(queueManager.get(item.id)?.retryCount).toBe(2);
});
});
describe('getAll', () => {
it('should return all queue items', () => {
queueManager.enqueue('https://instagram.com/p/test1');
queueManager.enqueue('https://instagram.com/p/test2');
queueManager.enqueue('https://instagram.com/p/test3');
const items = queueManager.getAll();
expect(items).toHaveLength(3);
});
const items = queueManager.getAll();
expect(items).toHaveLength(3);
});
it('should return empty array when queue is empty', () => {
const items = queueManager.getAll();
expect(items).toEqual([]);
});
});
it('should return empty array when queue is empty', () => {
const items = queueManager.getAll();
expect(items).toEqual([]);
});
});
describe('get', () => {
it('should return item by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
describe('get', () => {
it('should return item by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const retrieved = queueManager.get(item.id);
expect(retrieved?.id).toBe(item.id);
expect(retrieved?.url).toBe(item.url);
});
const retrieved = queueManager.get(item.id);
expect(retrieved?.id).toBe(item.id);
expect(retrieved?.url).toBe(item.url);
});
it('should return undefined for non-existent ID', () => {
const item = queueManager.get('non-existent-id');
expect(item).toBeUndefined();
});
});
it('should return undefined for non-existent ID', () => {
const item = queueManager.get('non-existent-id');
expect(item).toBeUndefined();
});
});
describe('subscribe', () => {
it('should notify subscribers of updates', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
describe('subscribe', () => {
it('should notify subscribers of updates', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test');
queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalled();
});
expect(callback).toHaveBeenCalled();
});
it('should return unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = queueManager.subscribe(callback);
it('should return unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test1');
expect(callback).toHaveBeenCalledTimes(1);
queueManager.enqueue('https://instagram.com/p/test1');
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
callback.mockClear();
unsubscribe();
callback.mockClear();
queueManager.enqueue('https://instagram.com/p/test2');
expect(callback).not.toHaveBeenCalled();
});
queueManager.enqueue('https://instagram.com/p/test2');
expect(callback).not.toHaveBeenCalled();
});
it('should handle subscriber errors gracefully', () => {
const goodCallback = vi.fn();
const badCallback = vi.fn(() => {
throw new Error('Subscriber error');
});
it('should handle subscriber errors gracefully', () => {
const goodCallback = vi.fn();
const badCallback = vi.fn(() => {
throw new Error('Subscriber error');
});
queueManager.subscribe(goodCallback);
queueManager.subscribe(badCallback);
queueManager.subscribe(goodCallback);
queueManager.subscribe(badCallback);
// Should not throw despite bad callback
expect(() => {
queueManager.enqueue('https://instagram.com/p/test');
}).not.toThrow();
// Should not throw despite bad callback
expect(() => {
queueManager.enqueue('https://instagram.com/p/test');
}).not.toThrow();
// Good callback should still be called
expect(goodCallback).toHaveBeenCalled();
});
// Good callback should still be called
expect(goodCallback).toHaveBeenCalled();
});
it('should support multiple subscribers', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
it('should support multiple subscribers', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
queueManager.subscribe(callback1);
queueManager.subscribe(callback2);
queueManager.subscribe(callback3);
queueManager.subscribe(callback1);
queueManager.subscribe(callback2);
queueManager.subscribe(callback3);
queueManager.enqueue('https://instagram.com/p/test');
queueManager.enqueue('https://instagram.com/p/test');
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
expect(callback3).toHaveBeenCalled();
});
});
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
expect(callback3).toHaveBeenCalled();
});
});
});

View File

@@ -2,19 +2,19 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock parser to avoid LLM calls
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
name: 'Test Recipe',
ingredients: [],
instructions: 'Test instructions',
servings: 4
}),
detectRecipe: vi.fn().mockResolvedValue(true)
extractRecipe: vi.fn().mockResolvedValue({
name: 'Test Recipe',
ingredients: [],
instructions: 'Test instructions',
servings: 4
}),
detectRecipe: vi.fn().mockResolvedValue(true)
}));
// Mock tandoor to avoid API calls
vi.mock('$lib/server/tandoor', () => ({
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({ id: 123 }),
uploadRecipeImage: vi.fn().mockResolvedValue(true)
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({ id: 123 }),
uploadRecipeImage: vi.fn().mockResolvedValue(true)
}));
import { queueManager } from '$lib/server/queue/QueueManager';
@@ -22,72 +22,74 @@ import * as extraction from '$lib/server/extraction';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
describe('QueueProcessor logging', () => {
let consoleErrorSpy: any;
let consoleErrorSpy: any;
beforeEach(async () => {
// Stop processor first
queueProcessor.stop();
beforeEach(async () => {
// Stop processor first
queueProcessor.stop();
// Clear queue
const items = queueManager.getAll();
items.forEach((item) => queueManager.remove(item.id));
// Clear queue
const items = queueManager.getAll();
items.forEach(item => queueManager.remove(item.id));
// Setup console.error spy
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Setup console.error spy
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Give time for cleanup
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Give time for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
});
afterEach(() => {
queueProcessor.stop();
consoleErrorSpy.mockRestore();
});
afterEach(() => {
queueProcessor.stop();
consoleErrorSpy.mockRestore();
});
test('error logs should be properly serialized (no [object Object])', async () => {
// Create complex error object
const complexError = new Error('Test extraction error');
(complexError as any).code = 'ERR_TEST';
(complexError as any).details = { phase: 'extraction', retries: 3 };
test('error logs should be properly serialized (no [object Object])', async () => {
// Create complex error object
const complexError = new Error('Test extraction error');
(complexError as any).code = 'ERR_TEST';
(complexError as any).details = { phase: 'extraction', retries: 3 };
// Mock extraction to fail BEFORE starting processor
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
extractSpy.mockRejectedValueOnce(complexError);
// Mock extraction to fail BEFORE starting processor
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
extractSpy.mockRejectedValueOnce(complexError);
const item = queueManager.enqueue('https://instagram.com/p/TEST');
queueProcessor.start();
const item = queueManager.enqueue('https://instagram.com/p/TEST');
queueProcessor.start();
// Wait for error status
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
return updated?.status === 'error' || updated?.status === 'unhealthy';
},
{ timeout: 5000 }
);
// Wait for error status
await vi.waitFor(() => {
const updated = queueManager.get(item.id);
return updated?.status === 'error' || updated?.status === 'unhealthy';
}, { timeout: 5000 });
// Stop processor
queueProcessor.stop();
// Stop processor
queueProcessor.stop();
// Wait a bit for all logs to finish
await new Promise((resolve) => setTimeout(resolve, 100));
// Wait a bit for all logs to finish
await new Promise(resolve => setTimeout(resolve, 100));
// Check that console.error doesn't contain [object Object]
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
call
.map((arg) => {
if (arg && typeof arg === 'object' && arg.message) {
return arg.message; // Handle Error objects
}
return String(arg);
})
.join(' ')
);
// Check that console.error doesn't contain [object Object]
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
call.map(arg => {
if (arg && typeof arg === 'object' && arg.message) {
return arg.message; // Handle Error objects
}
return String(arg);
}).join(' ')
);
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
// Verify QueueProcessor logs are present
const queueProcessorLogs = allCalls.filter((msg: string) => msg.includes('[QueueProcessor]'));
// Verify QueueProcessor logs are present
const queueProcessorLogs = allCalls.filter((msg: string) =>
msg.includes('[QueueProcessor]')
);
expect(queueProcessorLogs.length).toBeGreaterThan(0);
});
expect(queueProcessorLogs.length).toBeGreaterThan(0);
});
});

View File

@@ -10,55 +10,56 @@ import { queueManager } from '$lib/server/queue/QueueManager';
// Mock web-push module BEFORE importing modules that depend on it
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined)
}
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined)
}
}));
// 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: 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: 'mailto:test@example.com'
}
}
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: {
enabled: true,
token: 'test-token',
serverUrl: 'http://localhost:8080'
},
push: {
vapidPublicKey:
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: 'mailto:test@example.com'
}
}
}));
// Mock external dependencies BEFORE importing QueueProcessor
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
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'
})
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
})
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
success: true,
recipeId: 999
}),
uploadRecipeImage: vi.fn().mockResolvedValue({
success: true
})
}));
import { extractTextAndThumbnail } from '$lib/server/extraction';
@@ -70,197 +71,195 @@ import * as configModule from '$lib/server/queue/config';
import '$lib/server/queue/QueueProcessor';
describe('QueueProcessor Integration Tests', () => {
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach(item => queueManager.remove(item.id));
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
// Reset mocks and their implementations
vi.resetAllMocks();
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
servings: 2,
ingredients: [{ item: 'ingredient 1', amount: '1', unit: 'piece' }],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
servings: 2,
ingredients: [{ item: 'ingredient 1', amount: '1', unit: 'piece' }],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
afterEach(async () => {
// Wait for any pending processing to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
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'
});
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',
servings: 4,
ingredients: [
{ item: 'flour', amount: '2', unit: 'cups' },
{ item: 'eggs', amount: '2', unit: 'pieces' }
],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Test Recipe',
servings: 4,
ingredients: [
{ item: 'flour', amount: '2', unit: 'cups' },
{ item: 'eggs', amount: '2', unit: 'pieces' }
],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 123
});
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');
// 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));
// Wait for processing to complete - increased timeout
await new Promise((resolve) => setTimeout(resolve, 1000));
const updated = queueManager.get(item.id);
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 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
// 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
}
});
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(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
await new Promise((resolve) => setTimeout(resolve, 800));
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
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();
// 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);
// Restore mock
vi.restoreAllMocks();
}, 10000);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
new Error('Network timeout')
);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(new Error('Network timeout'));
const item = queueManager.enqueue('https://instagram.com/p/error');
const item = queueManager.enqueue('https://instagram.com/p/error');
await new Promise((resolve) => setTimeout(resolve, 800));
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
const updated = queueManager.get(item.id);
// Should mark as unhealthy (recoverable)
expect(updated?.status).toBe('unhealthy');
expect(updated?.error?.message).toContain('timeout');
}, 10000);
// 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
});
it('should handle parsing failure', async () => {
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Not a recipe',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue(null);
vi.mocked(extractRecipe).mockResolvedValue(null);
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
await new Promise((resolve) => setTimeout(resolve, 800));
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
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);
// 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 };
});
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',
servings: null,
ingredients: [],
steps: [],
description: ''
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Concurrent Recipe',
servings: null,
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');
// 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));
// 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');
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);
// 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));
// Wait for all to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
const final = queueManager.getAll();
const completed = final.filter(i => i.status === 'success');
const final = queueManager.getAll();
const completed = final.filter((i) => i.status === 'success');
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
});

View File

@@ -9,133 +9,133 @@ import { queueManager } from '$lib/server/queue/QueueManager';
import { GET as streamGET } from '../routes/api/queue/stream/+server.js';
describe('Queue SSE Stream Endpoint', () => {
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
});
describe('GET /api/queue/stream', () => {
it('should return SSE response with correct headers', async () => {
const url = new URL('http://localhost/api/queue/stream');
const request = new Request(url);
describe('GET /api/queue/stream', () => {
it('should return SSE response with correct headers', async () => {
const url = new URL('http://localhost/api/queue/stream');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
// Connection header no longer manually set - managed automatically by Node.js
});
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
// Connection header no longer manually set - managed automatically by Node.js
});
it('should reject invalid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=invalid');
const request = new Request(url);
it('should reject invalid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=invalid');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toContain('Invalid status filter');
});
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toContain('Invalid status filter');
});
it('should reject invalid item ID format', async () => {
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
const request = new Request(url);
it('should reject invalid item ID format', async () => {
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Invalid queue item ID format');
});
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Invalid queue item ID format');
});
it('should accept valid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=pending');
const request = new Request(url);
it('should accept valid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=pending');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should accept valid item ID filter', async () => {
// Add a test item first
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
it('should accept valid item ID filter', async () => {
// Add a test item first
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
const request = new Request(url);
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should handle stream initialization without errors', async () => {
// Add some test items
queueManager.enqueue('https://instagram.com/p/TEST1');
queueManager.enqueue('https://instagram.com/p/TEST2');
it('should handle stream initialization without errors', async () => {
// Add some test items
queueManager.enqueue('https://instagram.com/p/TEST1');
queueManager.enqueue('https://instagram.com/p/TEST2');
const url = new URL('http://localhost/api/queue/stream');
const abortController = new AbortController();
const request = new Request(url, {
signal: abortController.signal
});
const url = new URL('http://localhost/api/queue/stream');
const abortController = new AbortController();
const request = new Request(url, {
signal: abortController.signal
});
const response = await streamGET({
url,
request
} as any);
const response = await streamGET({
url,
request
} as any);
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(ReadableStream);
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(ReadableStream);
// Abort the request to clean up
abortController.abort();
});
});
// Abort the request to clean up
abortController.abort();
});
});
// Note: Full SSE stream testing would require more complex setup with
// ReadableStream readers and async iteration, which is beyond the scope
// of these basic endpoint validation tests. The above tests verify that:
// 1. The endpoint responds correctly
// 2. Headers are set properly for SSE
// 3. Parameter validation works
// 4. Stream initialization succeeds
// Note: Full SSE stream testing would require more complex setup with
// ReadableStream readers and async iteration, which is beyond the scope
// of these basic endpoint validation tests. The above tests verify that:
// 1. The endpoint responds correctly
// 2. Headers are set properly for SSE
// 3. Parameter validation works
// 4. Stream initialization succeeds
});

View File

@@ -21,12 +21,12 @@ describe('SSE Extraction Endpoint', () => {
// Expected event flow
const expectedEventTypes = [
'status', // Starting extraction
'status', // Loading page
'method', // Trying first method
'status', // Success or next method
'status', // Parsing recipe
'complete' // Final result
'status', // Starting extraction
'status', // Loading page
'method', // Trying first method
'status', // Success or next method
'status', // Parsing recipe
'complete' // Final result
];
expect(expectedEventTypes).toBeDefined();
@@ -112,7 +112,7 @@ describe('Frontend SSE Parser', () => {
'embedded-json': '📦',
'dom-selector': '🎯',
'graphql-api': '🔌',
'legacy': '📄'
legacy: '📄'
};
return method ? icons[method] || '⚙️' : '⚙️';
};

View File

@@ -24,18 +24,13 @@ describe('tandoor logging', () => {
name: 'Test Recipe',
servings: 4,
description: 'Test description',
ingredients: [
{ item: 'Flour', amount: '2', unit: 'cups' }
],
ingredients: [{ item: 'Flour', amount: '2', unit: 'cups' }],
steps: ['Mix ingredients']
};
await uploadRecipeWithIngredientsDTO(recipe);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
});
test('should use logError on API error response', async () => {
@@ -80,10 +75,7 @@ describe('tandoor logging', () => {
await uploadRecipeWithIngredientsDTO(recipe);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
});
test('should use logError on image upload failure', async () => {
@@ -93,10 +85,7 @@ describe('tandoor logging', () => {
const result = await uploadRecipeImage(123, 'https://example.com/image.jpg');
expect(result.success).toBe(false);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor Upload] Exception',
error
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor Upload] Exception', error);
});
test('should use logError instead of manual error logging', async () => {
@@ -112,10 +101,7 @@ describe('tandoor logging', () => {
});
// Verify logError was called (which handles stack trace serialization)
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
error
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', error);
// logError itself logs stack traces, which is expected behavior
// The key is that tandoor.ts uses logError instead of manual logging

View File

@@ -5,22 +5,22 @@ import { sveltekit } from '@sveltejs/kit/vite';
import fs from 'fs';
export default defineConfig({
define: {
'process.env.NODE_ENV': process.env.NODE_ENV === 'production' ? '"production"' : '"development"'
},
server: {
watch: {
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
},
https: fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
? {
key: fs.readFileSync('./.ssl/localhost.key'),
cert: fs.readFileSync('./.ssl/localhost.crt')
}
: undefined
},
plugins: [
tailwindcss(), sveltekit()],
define: {
'process.env.NODE_ENV': process.env.NODE_ENV === 'production' ? '"production"' : '"development"'
},
server: {
watch: {
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
},
https:
fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
? {
key: fs.readFileSync('./.ssl/localhost.key'),
cert: fs.readFileSync('./.ssl/localhost.crt')
}
: undefined
},
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [