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

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);
});
});