Initial commit: Brachnha Insight project setup

- Next.js 14+ with App Router and TypeScript
- Tailwind CSS and ShadCN UI styling
- Zustand state management
- Dexie.js for IndexedDB (local-first data)
- Auth.js v5 for authentication
- BMAD framework integration

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Max
2026-01-26 12:28:43 +07:00
commit 3fbbb1a93b
812 changed files with 150531 additions and 0 deletions

53
tests/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Test Suite Documentation
This project uses **Playwright** for End-to-End (E2E) testing.
## Setup
1. Install dependencies:
\`\`\`bash
npm install
\`\`\`
2. Setup environment:
\`\`\`bash
cp .env.example .env
# Update .env with local configuration if needed
\`\`\`
3. Install Playwright browsers:
\`\`\`bash
npx playwright install
\`\`\`
## Running Tests
- **Run all E2E tests:**
\`\`\`bash
npm run test:e2e
\`\`\`
- **Run in UI mode (interactive):**
\`\`\`bash
npx playwright test --ui
\`\`\`
- **Run specific test file:**
\`\`\`bash
npx playwright test tests/e2e/example.spec.ts
\`\`\`
## Architecture
We follow a structured pattern for maintainable tests:
- **`tests/e2e/`**: Contains the actual test files.
- **`tests/support/fixtures/`**: Playwright fixtures. Use `test` from here, not `@playwright/test`.
- **`tests/support/fixtures/factories/`**: Data factories for generating test data.
### Best Practices
1. **Use `data-testid`**: Prefer `page.getByTestId('submit-btn')` over CSS/XPath selectors.
2. **Atomic Tests**: Each test should be independent and run in isolation.
3. **Use Factories**: Don't hardcode data. Use `userFactory.createUser()` to get fresh data.
4. **Network First**: When testing external APIs, use network interception to avoid flakiness.

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
// import { DeleteEntryDialog } from '../../src/components/journal/DeleteEntryDialog';
describe('DeleteEntryDialog', () => {
it('should show dialog and optimistically remove item from UI on confirm', async () => {
// GIVEN: Dialog is open
// const onDeleteMock = vi.fn();
// render(<DeleteEntryDialog isOpen={true} onDelete={onDeleteMock} />);
// WHEN: 'Delete' confirmed
// fireEvent.click(screen.getByText('Delete'));
// THEN: Dialog closes and onConfirm called
// expect(onDeleteMock).toHaveBeenCalled();
// For failing test purpose
expect(true).toBe(false);
});
});

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
import { createProviderConfig } from '../support/factories/provider.factory';
test.describe('Settings - API Provider Configuration', () => {
test.beforeEach(async ({ page }) => {
// Clear local storage to start fresh
await page.goto('/settings'); // Navigate first to access localStorage
await page.evaluate(() => localStorage.clear());
await page.reload();
});
test('should allow user to enter and save provider credentials', async ({ page }) => {
const providerData = createProviderConfig();
// GIVEN: User is on settings page
await page.goto('/settings');
// WHEN: User enters API Key and Base URL
await page.getByLabel('API Key').fill(providerData.apiKey);
await page.getByLabel('Base URL').fill(providerData.baseUrl);
await page.getByLabel('Model Name').fill(providerData.modelId);
// AND: User clicks Save
await page.getByRole('button', { name: 'Save' }).click();
// THEN: Success feedback is shown
await expect(page.getByText('Settings saved')).toBeVisible();
// AND: Values are persisted after reload
await page.reload();
await expect(page.getByLabel('API Key')).toHaveValue(providerData.apiKey);
await expect(page.getByLabel('Base URL')).toHaveValue(providerData.baseUrl);
await expect(page.getByLabel('Model Name')).toHaveValue(providerData.modelId);
});
test('should verify connection with valid credentials', async ({ page }) => {
const providerData = createProviderConfig();
// Setup network mock for "Hello" check
await page.route('**/models', async route => {
await route.fulfill({ status: 200, json: { data: [] } });
});
await page.goto('/settings');
await page.getByLabel('API Key').fill(providerData.apiKey);
await page.getByLabel('Base URL').fill(providerData.baseUrl);
// WHEN: User clicks "Test Connection"
await page.getByRole('button', { name: 'Test Connection' }).click();
// THEN: User sees success message
await expect(page.getByText('Connected ✅')).toBeVisible();
});
test('should show error for invalid connection', async ({ page }) => {
const providerData = createProviderConfig();
// Setup network mock for failure
await page.route('**/models', async route => {
await route.fulfill({ status: 401, json: { error: 'Invalid API Key' } });
});
await page.goto('/settings');
await page.getByLabel('API Key').fill(providerData.apiKey);
await page.getByLabel('Base URL').fill(providerData.baseUrl);
// WHEN: User clicks "Test Connection"
await page.getByRole('button', { name: 'Test Connection' }).click();
// THEN: User sees error message
await expect(page.getByText('Connection failed')).toBeVisible();
});
});

View File

@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
test('Chat Flow with Mocked LLM', async ({ page }) => {
// 1. Setup Mock API - must be set before navigation
await page.route('**/v1/chat/completions', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
choices: [{
message: { content: "This is a mock AI response." }
}]
})
});
});
// 2. Configure Settings
await page.goto('/settings');
await page.getByLabel('API Key').fill('sk-test-key');
await page.getByLabel('Base URL').fill('https://api.mock.com/v1');
await page.getByLabel('Model Name').fill('gpt-mock');
// Wait for settings to be saved (Zustand persist uses localStorage)
await page.waitForTimeout(500);
// 3. Go to Chat
await page.goto('/chat');
// Wait for empty state to appear (indicates session is ready)
await expect(page.getByRole('heading', { name: /frustrating you/i })).toBeVisible({ timeout: 5000 });
// 4. Send Message
const input = page.getByRole('textbox');
await input.fill('I hate writing tests.');
// Wait for button to be enabled
const sendButton = page.getByRole('button').first();
await expect(sendButton).toBeEnabled({ timeout: 3000 });
await sendButton.click();
// 5. Verify User Message - wait for it to appear in the chat
await expect(page.getByText('I hate writing tests.')).toBeVisible({ timeout: 10000 });
// 6. Verify AI Response
await expect(page.getByText('This is a mock AI response.')).toBeVisible({ timeout: 15000 });
});

77
tests/e2e/chat.spec.ts Normal file
View File

@@ -0,0 +1,77 @@
import { test, expect } from '../support/fixtures';
test.describe('Chat Interface (Story 1.2)', () => {
test.beforeEach(async ({ page }) => {
// GIVEN: User is on the homepage
await page.goto('/');
});
test('[P0] should allow user to send a message', async ({ page }) => {
// GIVEN: Input field is visible
const input = page.getByTestId('chat-input');
const sendButton = page.getByTestId('send-button');
// WHEN: User types "Hello" and clicks send
await input.fill('Hello World');
await sendButton.click();
// THEN: Input should be cleared
await expect(input).toHaveValue('');
// THEN: Message should appear in the chat
// We look for the bubble with the specific text
// Note: The app might render markdown, so exact text match usually works
await expect(page.getByTestId('chat-bubble-user')).toContainText('Hello World');
});
test('[P0] should display AI typing indicator', async ({ page }) => {
// This test relies on the simulation delay added in the store
// WHEN: User sends a message
await page.getByTestId('chat-input').fill('Tell me a story');
await page.getByTestId('send-button').click();
// THEN: Typing indicator should appear immediately (before AI response)
const indicator = page.getByTestId('typing-indicator');
await expect(indicator).toBeVisible();
// THEN: Typing indicator should disappear eventually (after response)
// The delay is simulated as 1000-2000ms in the store
await expect(indicator).toBeHidden({ timeout: 5000 });
// THEN: AI response should appear
await expect(page.getByTestId('chat-bubble-ai')).toBeVisible();
});
test('[P0] should persist messages across reload', async ({ page }) => {
// GIVEN: User sends a message
const uniqueMessage = `Persistence Test ${Date.now()}`;
await page.getByTestId('chat-input').fill(uniqueMessage);
await page.getByTestId('send-button').click();
// Wait for message to appear
await expect(page.getByText(uniqueMessage)).toBeVisible();
// WHEN: Page is reloaded
await page.reload();
// THEN: Message should still be visible
await expect(page.getByText(uniqueMessage)).toBeVisible();
});
test('[P1] should adapt to mobile viewport', async ({ page }) => {
// GIVEN: Mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
// WHEN: Page loads
await page.goto('/');
// THEN: Important elements are visible
await expect(page.getByTestId('chat-input')).toBeVisible();
await expect(page.getByTestId('send-button')).toBeVisible();
// Check elements fit within width (approximate check)
const inputBox = await page.getByTestId('chat-input').boundingBox();
expect(inputBox?.width).toBeLessThan(375); // Should have padding
});
});

28
tests/e2e/example.spec.ts Normal file
View File

@@ -0,0 +1,28 @@
import { test, expect } from '../support/fixtures';
test.describe('Example Test Suite', () => {
test('should load homepage', async ({ page }) => {
// Navigating to the base URL
await page.goto('/');
// Check that we're on the right page
// Note: Adjust the title expectation to match your actual app title
await expect(page).toHaveTitle(/Test01/i);
// Example of using data-testid selector (preferred)
// await expect(page.getByTestId('main-container')).toBeVisible();
});
test('should create valid user data using factory', async ({ userFactory }) => {
// This demonstrates using the factory to generate data
// Even though we don't login yet, it proves the factory works
const user = await userFactory.createUser();
expect(user.email).toBeTruthy();
expect(user.email).toContain('@');
expect(user.password).toBeTruthy();
// Console log to debug (remove in real tests)
console.log('Generated test user:', user.name);
});
});

View File

@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test';
test.describe('Ghostwriter Flow', () => {
test('Scenario 2.3.2 (P0): Regeneration respects user critique', async ({ page }) => {
// GIVEN: A generated draft exists (mocked)
await page.goto('/session/chat');
// Mock the "Drafting" completion state by injecting state or ensuring mock network response
await page.route('**/api/llm/generate', async (route) => {
const json = { content: 'Draft Version 1' };
await route.fulfill({ json });
});
// Trigger generation (assuming UI state is ready or fast-track used)
// For E2E, we might need to simulate the chat flow or use a "Debug: Force Draft" button if available in dev mode
// Assuming we can start from a state where the draft sheet is open for this test, or we navigate through chat.
// Let's assume we simulate the chat end first.
await page.fill('[data-testid="chat-input"]', 'Fast track this');
await page.click('[data-testid="fast-track-btn"]');
await expect(page.locator('[data-testid="draft-sheet"]')).toBeVisible();
await expect(page.locator('[data-testid="markdown-content"]')).toContainText('Draft Version 1');
// WHEN: User clicks Thumbs Down and critiques
await page.click('[data-testid="thumbs-down-btn"]');
await expect(page.locator('[data-testid="critique-input"]')).toBeVisible();
await page.fill('[data-testid="critique-input"]', 'Make it shorter');
// Intercept the regeneration request
let critiqueSent = false;
await page.route('**/api/llm/regenerate', async (route) => {
critiqueSent = true;
const postData = route.request().postDataJSON();
expect(postData.critique).toBe('Make it shorter');
await route.fulfill({ json: { content: 'Draft Version 2 (Shorter)' } });
});
await page.click('[data-testid="regenerate-submit-btn"]');
// THEN: A new draft is requested with critique context
await expect(page.locator('[data-testid="markdown-content"]')).toContainText('Draft Version 2 (Shorter)');
expect(critiqueSent).toBe(true);
});
test('Scenario 2.4.1 (P0): Copy button places text in clipboard', async ({ page, context }) => {
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
// GIVEN: A draft is visible
await page.goto('/session/chat');
// Mock generation
await page.route('**/api/llm/generate', async (route) => {
await route.fulfill({ json: { content: 'Final Draft Content' } });
});
await page.fill('[data-testid="chat-input"]', 'Fast track');
await page.click('[data-testid="fast-track-btn"]');
await expect(page.locator('[data-testid="draft-sheet"]')).toBeVisible();
// WHEN: User clicks Copy
await page.click('[data-testid="copy-btn"]');
// THEN: Clipboard contains text
// Note: Reading clipboard in headless might be tricky, checking for "Copied" toast is a good proxy for PWA checks
await expect(page.locator('[data-testid="toast-success"]')).toContainText('Copied to clipboard');
// Optional: layout check (R-2.5) - Verify rendering didn't break
await expect(page.locator('[data-testid="markdown-content"]')).toBeVisible();
await expect(page.locator('[data-testid="markdown-content"]')).not.toHaveCSS('overflow-x', 'hidden'); // Crude check for broken layout
});
});

View File

@@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test';
test.describe('Initial Load', () => {
test('should render empty state history feed for new user', async ({ page }) => {
// GIVEN: New user (empty DB)
// WHEN: Load home page
await page.goto('/');
// THEN: 'No entries yet' message visible
await expect(page.getByText('No entries yet')).toBeVisible();
});
});

View File

@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test';
test.describe('Epic 4: Power User Settings (BYOD)', () => {
test.beforeEach(async ({ page }) => {
// Clear storage to ensure clean state
await page.goto('/');
await page.evaluate(() => localStorage.clear());
// Mock API responses for validation
await page.route('**/chat/completions', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content: 'mock success' } }] })
});
});
});
test('P0: Provider Switching Configuration', async ({ page }) => {
// Navigate to settings
await page.goto('/settings');
await expect(page).toHaveURL(/.*settings/);
// 1. Add First Provider
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Fill Provider 1
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Mock Provider 1');
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://mock-provider-1.com/v1');
await page.getByRole('textbox', { name: /API Key/i }).fill('sk-key-1');
await page.getByRole('textbox', { name: /Model Name/i }).fill('model-1');
await page.getByRole('button', { name: /Save/i }).click();
// Verify Modal Closes (implicit success check)
await expect(page.getByRole('dialog')).toBeHidden();
// 2. Add Second Provider (Switching Test)
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Fill Provider 2
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Mock Provider 2');
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://mock-provider-2.com/v1');
await page.getByRole('textbox', { name: /API Key/i }).fill('sk-key-2');
await page.getByRole('textbox', { name: /Model Name/i }).fill('model-2');
await page.getByRole('button', { name: /Save/i }).click();
await expect(page.getByRole('dialog')).toBeHidden();
// 3. Verify Local Storage has the LATEST active provider (Model 2)
const settings = await page.evaluate(() => localStorage);
const storageString = JSON.stringify(settings);
console.log('Storage:', storageString);
expect(storageString).toContain('https://mock-provider-2.com/v1');
expect(storageString).toContain('model-2');
});
test('P0: Key Storage Security (Obfuscation)', async ({ page }) => {
await page.goto('/settings');
const secretKey = 'sk-secret-key-12345';
// Open Modal
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
// Fill Sensitive Data
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Security Test');
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://api.openai.com/v1');
await page.getByRole('textbox', { name: /API Key/i }).fill(secretKey);
await page.getByRole('textbox', { name: /Model Name/i }).fill('gpt-4');
await page.getByRole('button', { name: /Save/i }).click();
await expect(page.getByRole('dialog')).toBeHidden();
// Verify key is NOT stored in plain text
const settings = await page.evaluate(() => localStorage);
const storageValues = Object.values(settings).join('');
// The raw key should NOT be found exactly as entered if obfuscation works
// Note: If this fails, it means Security P0 Failed (Critical Issue)
expect(storageValues).not.toContain(secretKey);
});
});

View File

@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
test.describe('Deletion Persistence', () => {
test('should permanently remove item from Dexie DB', async () => {
// GIVEN: Item exists in DB
// WHEN: Delete action (via Service or UI)
// THEN: Item is gone from DB
expect(true).toBe(false);
});
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test';
test.describe('Ghostwriter Persistence', () => {
test('Scenario 2.4.2 (P0): Session Saved as Completed', async ({ request }) => {
// GIVEN: A chat session flow is completed
// This is an API/Integration test, so we hit the endpoint directly if possible,
// OR we use the service capability if we were in a unit test.
// For Playwright Integration (API), we assume an endpoint exists to finalize.
// Setup: Create a session
const createRes = await request.post('/api/sessions', { data: { title: 'Test Session' } });
const { sessionId } = await createRes.json();
// WHEN: User saves the final draft
const saveRes = await request.post(`/api/sessions/${sessionId}/finalize`, {
data: { finalContent: 'My Lesson' }
});
expect(saveRes.status()).toBe(200);
// THEN: Session status is COMPLETED
const getRes = await request.get(`/api/sessions/${sessionId}`);
const session = await getRes.json();
expect(session.status).toBe('COMPLETED');
expect(session.finalArtifact).toBe('My Lesson');
});
});

View File

@@ -0,0 +1,79 @@
import { expect } from '@playwright/test';
import { test } from '../support/fixtures/offline.fixture';
test.describe('Offline Action Queueing', () => {
test('should enqueue SAVE_DRAFT action when offline', async ({ offlineControl, page }) => {
// GIVEN: App is loaded
await page.goto('/');
await page.waitForFunction(() => (window as any).db !== undefined && (window as any).DraftService !== undefined, { timeout: 10000 });
// GIVEN: App goes offline
await offlineControl.goOffline(page.context());
// WHEN: DraftService.saveDraft is called (Service Integration)
await page.evaluate(async () => {
// @ts-ignore
await window.DraftService.saveDraft({
title: 'Offline Draft',
content: 'Content written offline',
tags: [],
createdAt: Date.now(),
status: 'draft',
sessionId: 'test-session'
});
});
// THEN: 'syncQueue' table has 1 item
const queueCount = await page.evaluate(async () => {
// @ts-ignore
return await window.db.syncQueue.count();
});
expect(queueCount).toBe(1);
const firstItem = await page.evaluate(async () => {
// @ts-ignore
return await window.db.syncQueue.orderBy('createdAt').first();
});
expect(firstItem.action).toBe('saveDraft');
expect(firstItem.payload.draftData.title).toBe('Offline Draft');
});
test('should enqueue DELETE_ENTRY action when offline', async ({ offlineControl, page }) => {
// GIVEN: App is loaded and has an entry
await page.goto('/');
await page.waitForFunction(() => (window as any).db !== undefined && (window as any).DraftService !== undefined, { timeout: 10000 });
// Setup entry
const draftId = await page.evaluate(async () => {
// @ts-ignore
const id = await window.db.drafts.add({
title: 'To Delete',
content: 'Delete me',
tags: [],
createdAt: Date.now(),
status: 'draft',
sessionId: 'test-session'
});
return id;
});
await offlineControl.goOffline(page.context());
// WHEN: DraftService.deleteDraft is called
await page.evaluate(async (id) => {
// @ts-ignore
await window.DraftService.deleteDraft(id);
}, draftId);
// THEN: SyncQueue has delete action
const lastItem = await page.evaluate(async () => {
// @ts-ignore
return await window.db.syncQueue.orderBy('createdAt').last();
});
expect(lastItem.action).toBe('deleteDraft');
expect(lastItem.payload.draftId).toBe(draftId);
});
});

View File

@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';
test.describe('PWA Manifest', () => {
test('should serve a valid manifest.webmanifest', async ({ request }) => {
const response = await request.get('/manifest.webmanifest');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/manifest+json');
const manifest = await response.json();
// Core properties matching manifest.ts
expect(manifest.name).toBe('Test01');
expect(manifest.short_name).toBe('Test01');
expect(manifest.display).toBe('standalone');
expect(manifest.start_url).toBe('/');
expect(manifest.background_color).toBe('#F8FAFC');
expect(manifest.theme_color).toBe('#64748B');
// Icons
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
expect(manifest.icons).toEqual(
expect.arrayContaining([
expect.objectContaining({
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
}),
expect.objectContaining({
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
})
])
);
});
test('should have accessible icon files', async ({ request }) => {
const icon192 = await request.get('/icons/icon-192x192.png');
expect(icon192.status()).toBe(200);
const icon512 = await request.get('/icons/icon-512x512.png');
expect(icon512.status()).toBe(200);
});
});

View File

@@ -0,0 +1,40 @@
import { expect } from '@playwright/test';
import { test } from '../support/fixtures/offline.fixture';
test.describe('Sync Action Replay', () => {
test('should process pending actions when network restores', async ({ offlineControl, page }) => {
// GIVEN: Offline queue has actions
await page.goto('/'); // Ensure page is loaded to initialize DB
await page.waitForFunction(() => (window as any).db !== undefined, { timeout: 10000 });
await offlineControl.goOffline(page.context());
// Seed queue via evaluate
await page.evaluate(async () => {
// @ts-ignore
await window.db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { title: 'Pending Sync', content: '...' } },
status: 'pending',
createdAt: Date.now(),
retries: 0
});
});
// WHEN: Network restores
await offlineControl.goOnline(page.context());
// Trigger sync - The SyncManager listens to 'online' event.
// Note: The 'online' event listeners might execute async.
// Wait for processing
// We can poll the DB
await expect.poll(async () => {
return await page.evaluate(async () => {
// @ts-ignore
return await window.db.syncQueue.where('status').equals('pending').count();
});
}, { timeout: 5000 }).toBe(0);
});
});

View File

@@ -0,0 +1,48 @@
import { faker } from '@faker-js/faker';
export interface ChatSession {
id: string;
title: string;
date: string; // ISO string
preview: string;
tags: string[];
status: 'active' | 'completed';
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}>;
}
export const createChatSession = (overrides: Partial<ChatSession> = {}): ChatSession => {
const timestamp = faker.date.recent().getTime();
return {
id: faker.string.uuid(),
title: faker.lorem.sentence(3),
date: new Date(timestamp).toISOString(),
preview: faker.lorem.sentence(),
tags: [faker.word.sample(), faker.word.sample()],
status: 'completed',
messages: [
{
id: faker.string.uuid(),
role: 'user',
content: faker.lorem.paragraph(),
timestamp: timestamp - 10000,
},
{
id: faker.string.uuid(),
role: 'assistant',
content: faker.lorem.paragraph(),
timestamp: timestamp,
}
],
...overrides,
};
};
export const createChatSessions = (count: number, overrides: Partial<ChatSession> = {}): ChatSession[] => {
return Array.from({ length: count }, () => createChatSession(overrides));
};

View File

@@ -0,0 +1,24 @@
import { faker } from '@faker-js/faker';
export interface ProviderConfig {
id: string;
name: string;
baseUrl: string;
apiKey: string;
modelId: string;
}
export const createProviderConfig = (overrides: Partial<ProviderConfig> = {}): ProviderConfig => {
return {
id: faker.string.uuid(),
name: faker.company.name() + ' AI',
baseUrl: faker.internet.url(),
apiKey: 'sk-' + faker.string.alphanumeric(20),
modelId: faker.helpers.arrayElement(['gpt-4', 'claude-3', 'deepseek-chat']),
...overrides,
};
};
export const createProviderConfigs = (count: number): ProviderConfig[] => {
return Array.from({ length: count }, () => createProviderConfig());
};

View File

@@ -0,0 +1,50 @@
import { test as base } from '@playwright/test';
type DbFixture = {
resetDb: () => Promise<void>;
seedHistory: (data: any[]) => Promise<void>;
getHistory: () => Promise<any[]>;
getSyncQueue: () => Promise<any[]>;
};
export const test = base.extend<{ db: DbFixture }>({
db: async ({ page }, use) => {
const dbFixture = {
resetDb: async () => {
await page.evaluate(async () => {
// Assuming 'Dexie' is globally available or we use indexedDB directly
// Using indexedDB directly to be safe as Dexie might be bundled
const req = indexedDB.deleteDatabase('Test01DB'); // Match your DB name
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(true);
req.onerror = () => reject(req.error);
req.onblocked = () => console.warn('Delete DB blocked');
});
});
},
seedHistory: async (data: any[]) => {
// This is tricky if logic is encapsulated.
// We might need to expose a hidden window helper or just rely on UI for seeding in E2E
// OR use a specialized seeding script.
// For Integration/Component, we might assume we can import the DB client directly if running in same context.
// Since playwight runs in browser context, we'd need to expose it.
// For now, let's assume we can invoke a window helper if useful, or just implement specific logic
await page.evaluate(async (items) => {
// We'll need the app to expose a way to seed, or we assume specific DB structure
// This requires the app to have the DB initialized.
// Implementation deferred to actual test via page.evaluate if needed
}, data);
},
getHistory: async () => {
// Placeholder for getting history from DB
return [];
},
getSyncQueue: async () => {
// Placeholder
return [];
}
};
await use(dbFixture);
},
});

View File

@@ -0,0 +1,37 @@
import { faker } from '@faker-js/faker';
/**
* UserFactory
*
* Handles creation and cleanup of test users.
* Note: Since this is a local-first app without a real backend API for user creation yet,
* this factory currently generates mock data. adapting to real API calls later.
*/
export class UserFactory {
// In a real app, we would track IDs here for cleanup
// private createdUserIds: string[] = [];
async createUser(overrides = {}) {
const user = {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
password: faker.internet.password(),
createdAt: new Date().toISOString(),
...overrides,
};
// Placeholder: In a real app, you would POST to API here
// const response = await fetch(\`\${process.env.API_URL}/users\`, ...);
return user;
}
async cleanup() {
// Placeholder: In a real app, you would DELETE users here
// for (const id of this.createdUserIds) { ... }
// For now, no cleanup needed for transient mock data
return Promise.resolve();
}
}

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test';
import { UserFactory } from './factories/user-factory';
type TestFixtures = {
userFactory: UserFactory;
};
export const test = base.extend<TestFixtures>({
userFactory: async ({ }, use) => {
const factory = new UserFactory();
await use(factory);
await factory.cleanup();
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,27 @@
import { test as base, BrowserContext } from '@playwright/test';
type OfflineFixture = {
goOffline: (context: BrowserContext) => Promise<void>;
goOnline: (context: BrowserContext) => Promise<void>;
};
export const test = base.extend<{ offlineControl: OfflineFixture }>({
offlineControl: async ({ }, use) => {
const offlineFixture = {
goOffline: async (context: BrowserContext) => {
await context.setOffline(true);
for (const page of context.pages()) {
await page.evaluate(() => window.dispatchEvent(new Event('offline'))).catch(() => { });
}
},
goOnline: async (context: BrowserContext) => {
await context.setOffline(false);
for (const page of context.pages()) {
await page.evaluate(() => window.dispatchEvent(new Event('online'))).catch(() => { });
}
},
};
await use(offlineFixture);
},
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
// @ts-ignore - Module likely not created yet
import { PromptEngine } from '@/lib/llm/prompt-engine';
describe('PromptEngine (Ghostwriter)', () => {
it('should construct a grounded prompt with user insight', () => {
// GIVEN: A user insight and chat history
const history = [
{ role: 'user', content: 'I feel stupid.' },
{ role: 'assistant', content: 'Why?' },
{ role: 'user', content: 'I made a typo.' }
];
const insight = "I need a checklist for typos.";
// WHEN: Constructing the prompt
// @ts-ignore
const prompt = PromptEngine.constructGhostwriterPrompt(history, insight);
// THEN: The system prompt should enforce grounding
expect(prompt[0].role).toBe('system');
expect(prompt[0].content).toContain('You are an expert Ghostwriter');
expect(prompt[0].content).toContain('Ground your answer in the user insight'); // R-2.1 Mitigation
// AND: The user insight should be clearly labeled
const lastMessage = prompt[prompt.length - 1];
expect(lastMessage.content).toContain(`INSIGHT: ${insight}`);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SecureStorage } from '../../../src/services/secure-storage'; // Implementation doesn't exist yet
describe('SecureStorage Service', () => {
const TEST_KEY = 'test_api_key_123';
const STORAGE_KEY = 'llm_provider_config';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('should save API key with basic encoding (not plain text)', () => {
SecureStorage.saveKey(TEST_KEY);
// Direct access to localStorage to verify obfuscation
const storedValue = localStorage.getItem(STORAGE_KEY);
expect(storedValue).toBeDefined();
expect(storedValue).not.toBe(TEST_KEY); // Should NOT be plain text
expect(storedValue).not.toContain(TEST_KEY); // Should not contain the key
});
it('should retrieve the original API key correctly', () => {
SecureStorage.saveKey(TEST_KEY);
const retrievedKey = SecureStorage.getKey();
expect(retrievedKey).toBe(TEST_KEY);
});
it('should return null if no key is stored', () => {
const retrievedKey = SecureStorage.getKey();
expect(retrievedKey).toBeNull();
});
it('should overwrite existing key when saving new one', () => {
SecureStorage.saveKey('old_key');
SecureStorage.saveKey('new_key');
expect(SecureStorage.getKey()).toBe('new_key');
});
it('should clear key when requested', () => {
SecureStorage.saveKey(TEST_KEY);
SecureStorage.clearKey();
expect(SecureStorage.getKey()).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SyncManager } from '../../../src/services/sync-manager';
// Define hoisted mocks to be accessible inside vi.mock
const { mockAdd, mockWhere, mockUpdate, mockDelete, mockSortBy } = vi.hoisted(() => {
const mockSortBy = vi.fn();
const mockEquals = vi.fn(() => ({ sortBy: mockSortBy }));
const mockWhere = vi.fn(() => ({ equals: mockEquals }));
return {
mockAdd: vi.fn(),
mockWhere,
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockSortBy
};
});
// Mock Dexie
vi.mock('../../../src/lib/db', () => ({
db: {
syncQueue: {
add: mockAdd,
where: mockWhere,
update: mockUpdate,
delete: mockDelete,
},
},
}));
describe('SyncManager Service', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default return for sortBy (mockPendingItems)
mockSortBy.mockResolvedValue([]);
});
describe('queueAction', () => {
it('should add action to syncQueue with pending status', async () => {
// GIVEN: A draft payload
const payload = { draftData: { title: 'Test', content: 'Content' } as any };
mockAdd.mockResolvedValue(1);
// WHEN: queueAction is called
const id = await SyncManager.queueAction('saveDraft', payload);
// THEN: It returns the new ID
expect(id).toBe(1);
// AND: It calls db.syncQueue.add with correct data
expect(mockAdd).toHaveBeenCalledWith({
action: 'saveDraft',
payload,
status: 'pending',
createdAt: expect.any(Number),
retries: 0,
});
});
});
describe('processQueue', () => {
it('should process pending items in order', async () => {
// GIVEN: Pending items in queue
const items = [
{ id: 1, action: 'saveDraft', payload: {}, status: 'pending', retries: 0 },
{ id: 2, action: 'deleteDraft', payload: { draftId: 123 }, status: 'pending', retries: 0 },
];
mockSortBy.mockResolvedValue(items);
// WHEN: processQueue is called
await SyncManager.processQueue();
// THEN: Items are processed
// Verify update to 'processing'
expect(mockUpdate).toHaveBeenCalledWith(1, { status: 'processing' });
expect(mockUpdate).toHaveBeenCalledWith(2, { status: 'processing' });
// Verify deletion after success (assuming success path)
expect(mockDelete).toHaveBeenCalledWith(1);
expect(mockDelete).toHaveBeenCalledWith(2);
});
it('should handle execution errors and increment retries', async () => {
// GIVEN: An item that will fail execution (invalid action)
const item = {
id: 1,
action: 'invalidAction', // Will throw error in executeAction
payload: {},
status: 'pending',
retries: 0
};
mockSortBy.mockResolvedValue([item]);
// WHEN: processQueue is called
await SyncManager.processQueue();
// THEN: Item should be updated to processing
expect(mockUpdate).toHaveBeenCalledWith(1, { status: 'processing' });
// AND: Should be updated with incremented retry count (not deleted)
expect(mockUpdate).toHaveBeenCalledWith(1, {
status: 'pending',
retries: 1
});
expect(mockDelete).not.toHaveBeenCalled();
});
});
});