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:
73
tests/e2e/04-settings-provider.spec.ts
Normal file
73
tests/e2e/04-settings-provider.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
tests/e2e/chat-flow.spec.ts
Normal file
46
tests/e2e/chat-flow.spec.ts
Normal 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
77
tests/e2e/chat.spec.ts
Normal 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
28
tests/e2e/example.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
tests/e2e/ghostwriter-flow.spec.ts
Normal file
68
tests/e2e/ghostwriter-flow.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
13
tests/e2e/initial-load.spec.ts
Normal file
13
tests/e2e/initial-load.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
86
tests/e2e/settings-byod.spec.ts
Normal file
86
tests/e2e/settings-byod.spec.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user