feat: Improve UI layout and navigation
- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility - Add logo as favicon - Add logo to mobile header - Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile) - Fix desktop chat layout - container structure prevents voice controls cutoff - Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons - Hide Create Node/New Conversation buttons on mobile to save header space - Make fixed header and voice controls work properly with containers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
177
tests/README.md
Normal file
177
tests/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Ponderants Test Suite
|
||||
|
||||
This directory contains all automated and manual tests for the Ponderants application.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── magnitude/ # Magnitude.run automated tests
|
||||
│ └── node-publishing.mag.ts
|
||||
├── helpers/ # Reusable test utilities
|
||||
│ ├── playwright-helpers.ts
|
||||
│ └── README.md
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Test Frameworks
|
||||
|
||||
### Magnitude.run
|
||||
AI-powered end-to-end testing framework that uses vision to interact with the browser like a human.
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
pnpm test
|
||||
# or
|
||||
npx magnitude
|
||||
```
|
||||
|
||||
**Test files:** `*.mag.ts` in the `magnitude/` directory
|
||||
|
||||
### Playwright MCP
|
||||
Interactive browser automation for manual testing and debugging.
|
||||
|
||||
**Usage:** Use the Playwright MCP tools with helper functions from `helpers/playwright-helpers.ts`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Node Publishing Flow (`magnitude/node-publishing.mag.ts`)
|
||||
|
||||
**Happy Path Tests:**
|
||||
- ✅ User can publish a node from conversation
|
||||
- ✅ User can edit node draft before publishing
|
||||
- ✅ User can cancel node draft without publishing
|
||||
|
||||
**Unhappy Path Tests:**
|
||||
- ✅ Cannot publish node without authentication
|
||||
- ✅ Cannot publish node with empty title
|
||||
- ✅ Cannot publish node with empty content
|
||||
- ✅ Shows error notification if publish fails
|
||||
- ✅ Handles long content with truncation
|
||||
- ✅ Shows warning when cache fails but publish succeeds
|
||||
|
||||
**Integration Tests:**
|
||||
- ✅ Complete user journey: Login → Converse → Publish → View
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Configure Test Environment
|
||||
```bash
|
||||
cp .env.test.example .env.test
|
||||
```
|
||||
|
||||
Edit `.env.test` and add your test credentials:
|
||||
```env
|
||||
TEST_BLUESKY_USERNAME=your-test-user.bsky.social
|
||||
TEST_BLUESKY_PASSWORD=your-test-password
|
||||
```
|
||||
|
||||
**Important:** Use a dedicated test account, not your personal account!
|
||||
|
||||
### 3. Start Development Server
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The test server must be running on `http://localhost:3000` before running tests.
|
||||
|
||||
### 4. Run Tests
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Using Magnitude
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('Test description', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Describe the action');
|
||||
await agent.check('Verify the result');
|
||||
});
|
||||
```
|
||||
|
||||
### Using Helpers
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
import { loginWithBluesky, startConversation } from '../helpers/playwright-helpers';
|
||||
|
||||
test('Test with helpers', async (agent) => {
|
||||
const page = agent.page;
|
||||
|
||||
await loginWithBluesky(page);
|
||||
await startConversation(page, 'My test message');
|
||||
|
||||
await agent.check('Expected result');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Isolation:** Each test should be independent and not rely on previous tests
|
||||
2. **Test Data:** Use dedicated test accounts and test data
|
||||
3. **Cleanup:** Clean up any created data after tests (nodes, conversations)
|
||||
4. **Error Handling:** Test both happy paths and error scenarios
|
||||
5. **Documentation:** Comment complex test logic and edge cases
|
||||
6. **Reusability:** Use helper functions for common flows
|
||||
7. **Readability:** Use descriptive test names and clear assertions
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests should run on every pull request:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### View Test Execution
|
||||
Magnitude.run provides visual feedback during test execution.
|
||||
|
||||
### Interactive Testing with Playwright MCP
|
||||
Use Playwright MCP tools for step-by-step debugging:
|
||||
|
||||
```typescript
|
||||
import { loginWithBluesky } from './tests/helpers/playwright-helpers';
|
||||
|
||||
// In MCP session
|
||||
await loginWithBluesky(page, {
|
||||
username: 'test-user.bsky.social',
|
||||
password: 'test-password'
|
||||
});
|
||||
```
|
||||
|
||||
### Check Server Logs
|
||||
Monitor the dev server output for API errors:
|
||||
```bash
|
||||
pnpm dev
|
||||
# Watch for [POST /api/nodes] logs
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **OAuth Rate Limiting:** Repeated login tests may hit Bluesky rate limits
|
||||
- Solution: Use fewer login tests or implement session caching
|
||||
|
||||
2. **AI Response Times:** Chat tests may timeout on slow responses
|
||||
- Solution: Increase `waitForAIResponse` timeout
|
||||
|
||||
3. **Cache Failures:** SurrealDB cache may fail but shouldn't break tests
|
||||
- Expected: Tests should still pass with warning notifications
|
||||
|
||||
## Resources
|
||||
|
||||
- [Magnitude.run Documentation](https://magnitude.run/docs)
|
||||
- [Playwright Documentation](https://playwright.dev)
|
||||
- [ATProto OAuth Spec](https://atproto.com/specs/oauth)
|
||||
- [Bluesky API Docs](https://docs.bsky.app)
|
||||
34
tests/auth.setup.ts
Normal file
34
tests/auth.setup.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const authFile = path.join(__dirname, '../.playwright/.auth/user.json');
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Navigate to login page
|
||||
await page.goto('http://localhost:3000/login');
|
||||
|
||||
// Fill in the Bluesky handle
|
||||
await page.getByPlaceholder('e.g., yourname.bsky.social').fill('aprongecko.bsky.social');
|
||||
|
||||
// Click login button
|
||||
await page.getByRole('button', { name: 'Log in with Bluesky' }).click();
|
||||
|
||||
// Wait for OAuth redirect and handle it
|
||||
// This will open the Bluesky authorization page
|
||||
// In a real test, you would need to handle the OAuth flow
|
||||
// For now, we'll wait for the callback
|
||||
await page.waitForURL(/callback/, { timeout: 30000 });
|
||||
|
||||
// After successful auth, should redirect to chat
|
||||
await page.waitForURL(/chat/, { timeout: 10000 });
|
||||
|
||||
// Verify we're logged in
|
||||
await expect(page.getByText('Ponderants Interview')).toBeVisible();
|
||||
|
||||
// Save authenticated state
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
77
tests/helpers/README.md
Normal file
77
tests/helpers/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Playwright Test Helpers
|
||||
|
||||
This directory contains reusable helper functions for both Magnitude tests and Playwright MCP testing.
|
||||
|
||||
## Usage
|
||||
|
||||
### In Magnitude Tests
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
import {
|
||||
navigateToApp,
|
||||
loginWithBluesky,
|
||||
startConversation,
|
||||
createNodeDraft,
|
||||
publishNode,
|
||||
completeNodePublishFlow,
|
||||
} from '../helpers/playwright-helpers';
|
||||
|
||||
test('User can publish a node', async (agent) => {
|
||||
const page = agent.page; // Get Playwright page from Magnitude agent
|
||||
|
||||
await completeNodePublishFlow(page, 'My test conversation');
|
||||
|
||||
await agent.check('Node published successfully');
|
||||
});
|
||||
```
|
||||
|
||||
### In Playwright MCP
|
||||
|
||||
Use the helper functions directly with the Playwright MCP page instance:
|
||||
|
||||
```typescript
|
||||
import { loginWithBluesky, startConversation } from './tests/helpers/playwright-helpers';
|
||||
|
||||
// In your MCP session
|
||||
await loginWithBluesky(page);
|
||||
await startConversation(page, 'Hello AI');
|
||||
```
|
||||
|
||||
## Available Helpers
|
||||
|
||||
### Authentication
|
||||
- `loginWithBluesky(page, credentials?)` - Complete OAuth login flow
|
||||
- `logout(page)` - Logout from application
|
||||
- `isLoggedIn(page)` - Check if user is authenticated
|
||||
|
||||
### Navigation
|
||||
- `navigateToApp(page, baseUrl?)` - Go to app home page
|
||||
|
||||
### Conversation
|
||||
- `startConversation(page, message)` - Send first message in chat
|
||||
- `waitForAIResponse(page, timeout?)` - Wait for AI to finish responding
|
||||
|
||||
### Node Publishing
|
||||
- `createNodeDraft(page)` - Click "Create Node" button
|
||||
- `editNodeDraft(page, title?, content?)` - Modify draft before publishing
|
||||
- `publishNode(page)` - Click "Publish" and wait for success
|
||||
- `completeNodePublishFlow(page, message?, credentials?)` - Full end-to-end flow
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set these in your `.env.test` file:
|
||||
|
||||
```env
|
||||
TEST_BLUESKY_USERNAME=your-test-user.bsky.social
|
||||
TEST_BLUESKY_PASSWORD=your-test-password
|
||||
```
|
||||
|
||||
If not set, helpers will use default values (which will fail for real OAuth).
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Reusability** - Each helper is atomic and can be composed
|
||||
2. **Reliability** - Helpers wait for network and UI state before proceeding
|
||||
3. **Flexibility** - Optional parameters allow customization
|
||||
4. **Error Handling** - Helpers throw clear errors when expectations aren't met
|
||||
216
tests/helpers/playwright-helpers.ts
Normal file
216
tests/helpers/playwright-helpers.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Playwright Helper Functions
|
||||
*
|
||||
* Reusable test utilities for both Magnitude tests and Playwright MCP.
|
||||
* These helpers encapsulate common test flows to reduce duplication.
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export interface TestCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default test credentials for Bluesky OAuth login.
|
||||
* In production tests, these should come from environment variables.
|
||||
*/
|
||||
export const DEFAULT_TEST_CREDENTIALS: TestCredentials = {
|
||||
username: process.env.TEST_BLUESKY_USERNAME || 'test-user.bsky.social',
|
||||
password: process.env.TEST_BLUESKY_PASSWORD || 'test-password',
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to the application home page and wait for it to load.
|
||||
*/
|
||||
export async function navigateToApp(page: Page, baseUrl: string = 'http://localhost:3000'): Promise<void> {
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the Bluesky OAuth login flow.
|
||||
*
|
||||
* This function:
|
||||
* 1. Clicks the "Log in with Bluesky" button
|
||||
* 2. Waits for Bluesky OAuth page to load
|
||||
* 3. Fills in credentials
|
||||
* 4. Submits the form
|
||||
* 5. Waits for redirect back to the app
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
* @param credentials - Optional custom credentials (defaults to DEFAULT_TEST_CREDENTIALS)
|
||||
*/
|
||||
export async function loginWithBluesky(
|
||||
page: Page,
|
||||
credentials: TestCredentials = DEFAULT_TEST_CREDENTIALS
|
||||
): Promise<void> {
|
||||
// Click login button
|
||||
await page.click('text="Log in with Bluesky"');
|
||||
|
||||
// Wait for Bluesky OAuth page
|
||||
await page.waitForURL(/bsky\.social|bsky\.app/);
|
||||
|
||||
// Fill in credentials
|
||||
await page.fill('input[name="identifier"]', credentials.username);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
|
||||
// Submit login form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect back to app
|
||||
await page.waitForURL(/localhost:3000/);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new conversation by sending a message in the chat interface.
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
* @param message - The message to send
|
||||
*/
|
||||
export async function startConversation(page: Page, message: string): Promise<void> {
|
||||
// Find the chat input (textarea or input field)
|
||||
const chatInput = page.locator('textarea, input[type="text"]').first();
|
||||
|
||||
// Type the message
|
||||
await chatInput.fill(message);
|
||||
|
||||
// Submit (press Enter or click send button)
|
||||
await chatInput.press('Enter');
|
||||
|
||||
// Wait for AI response to appear
|
||||
await page.waitForSelector('text=/AI|Assistant|thinking/i', { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a node draft from the current conversation.
|
||||
*
|
||||
* This assumes:
|
||||
* - User is already in a conversation
|
||||
* - The "Create Node" button is visible
|
||||
*/
|
||||
export async function createNodeDraft(page: Page): Promise<void> {
|
||||
// Click "Create Node" button
|
||||
await page.click('text="Create Node"');
|
||||
|
||||
// Wait for navigation to edit page
|
||||
await page.waitForURL(/\/edit/);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify we're on the edit page with draft content
|
||||
await page.waitForSelector('input[value]:not([value=""])', { timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a node from the edit page.
|
||||
*
|
||||
* This assumes:
|
||||
* - User is already on the /edit page
|
||||
* - Draft content is loaded
|
||||
*/
|
||||
export async function publishNode(page: Page): Promise<void> {
|
||||
// Click "Publish Node" button
|
||||
await page.click('text="Publish Node"');
|
||||
|
||||
// Wait for success notification
|
||||
await page.waitForSelector('text=/Node published|success/i', { timeout: 15000 });
|
||||
|
||||
// Wait for navigation back to conversation view
|
||||
await page.waitForURL(/\/chat|\/conversation/, { timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete end-to-end flow: Login → Conversation → Create Node → Publish
|
||||
*
|
||||
* This is the "happy path" test flow for the core user journey.
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
* @param message - Message to start conversation with
|
||||
* @param credentials - Optional custom credentials
|
||||
*/
|
||||
export async function completeNodePublishFlow(
|
||||
page: Page,
|
||||
message: string = 'Test conversation for node creation',
|
||||
credentials: TestCredentials = DEFAULT_TEST_CREDENTIALS
|
||||
): Promise<void> {
|
||||
await navigateToApp(page);
|
||||
await loginWithBluesky(page, credentials);
|
||||
await startConversation(page, message);
|
||||
await createNodeDraft(page);
|
||||
await publishNode(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit node draft content before publishing.
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
* @param title - New title (undefined to skip)
|
||||
* @param content - New content (undefined to skip)
|
||||
*/
|
||||
export async function editNodeDraft(
|
||||
page: Page,
|
||||
title?: string,
|
||||
content?: string
|
||||
): Promise<void> {
|
||||
if (title !== undefined) {
|
||||
const titleInput = page.locator('input[label="Title"], input[placeholder*="title" i]').first();
|
||||
await titleInput.fill(title);
|
||||
}
|
||||
|
||||
if (content !== undefined) {
|
||||
const contentInput = page.locator('textarea[label="Content"], textarea[placeholder*="content" i]').first();
|
||||
await contentInput.fill(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for AI to finish responding in the chat.
|
||||
*
|
||||
* This looks for the typing indicator to disappear.
|
||||
*/
|
||||
export async function waitForAIResponse(page: Page, timeoutMs: number = 30000): Promise<void> {
|
||||
// Wait for typing indicator to appear
|
||||
await page.waitForSelector('text=/thinking|typing|generating/i', {
|
||||
timeout: 5000,
|
||||
state: 'visible'
|
||||
}).catch(() => {
|
||||
// Indicator might appear and disappear quickly, that's okay
|
||||
});
|
||||
|
||||
// Wait for it to disappear
|
||||
await page.waitForSelector('text=/thinking|typing|generating/i', {
|
||||
timeout: timeoutMs,
|
||||
state: 'hidden'
|
||||
}).catch(() => {
|
||||
// Might already be gone, that's okay
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from the application.
|
||||
*/
|
||||
export async function logout(page: Page): Promise<void> {
|
||||
// Click user profile menu
|
||||
await page.click('[aria-label="User menu"], button:has-text("Profile")');
|
||||
|
||||
// Click logout button
|
||||
await page.click('text="Logout"');
|
||||
|
||||
// Wait for redirect to login page
|
||||
await page.waitForURL(/\/login|\/$/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is logged in by looking for authenticated UI elements.
|
||||
*/
|
||||
export async function isLoggedIn(page: Page): Promise<boolean> {
|
||||
try {
|
||||
// Look for chat interface or user menu
|
||||
await page.waitForSelector('textarea, [aria-label="User menu"]', { timeout: 2000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,131 @@
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('[Happy Path] User can record voice and see transcript', async (agent) => {
|
||||
// Act: Go to chat page
|
||||
await agent.act('Navigate to /chat');
|
||||
test('[Happy Path] User can have a full voice conversation with AI', async (agent) => {
|
||||
// Act: Navigate to chat page (assumes user is already authenticated)
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Check: Verify initial state
|
||||
await agent.check('The chat input field is empty');
|
||||
await agent.check('A "Start Recording" button is visible');
|
||||
// Check: Initial state - voice button shows "Start Voice Conversation"
|
||||
await agent.check('A button with text "Start Voice Conversation" is visible');
|
||||
|
||||
// Act: Click the record button
|
||||
// Note: This will require mocking the /api/voice-token response and the
|
||||
// MediaDevices/WebSocket browser APIs in a real test environment
|
||||
await agent.act('Click the "Start Recording" button');
|
||||
// Act: Click to start voice mode
|
||||
await agent.act('Click the "Start Voice Conversation" button');
|
||||
|
||||
// Check: UI updates to recording state
|
||||
await agent.check('A "Stop Recording" button is visible');
|
||||
|
||||
// Act: Simulate receiving a transcript from the (mocked) Deepgram WebSocket
|
||||
await agent.act(
|
||||
'Simulate an interim transcript "Hello world" from the Deepgram WebSocket'
|
||||
// Check: Button text changes to indicate checking or generating state
|
||||
// Could be "Checking for greeting..." or "Generating speech..." or "Listening..."
|
||||
await agent.check(
|
||||
'The button text has changed from "Start Voice Conversation" to indicate an active state'
|
||||
);
|
||||
|
||||
// Check: The input field is updated
|
||||
await agent.check('The chat input field contains "Hello world"');
|
||||
// Act: If there's a Skip button visible (greeting is playing), click it
|
||||
await agent.act('Click the Skip button if it is visible');
|
||||
|
||||
// Act: Simulate a final transcript
|
||||
await agent.act(
|
||||
'Simulate a final transcript "Hello world." from the Deepgram WebSocket'
|
||||
);
|
||||
// Check: Should transition to listening state
|
||||
await agent.check('The button shows "Listening... Start speaking"');
|
||||
|
||||
// Check: The "Stop Recording" button is gone
|
||||
await agent.check('A "Start Recording" button is visible again');
|
||||
// Check: Development test controls should be visible (in dev mode)
|
||||
await agent.check('A section with text "DEV: State Machine Testing" is visible');
|
||||
|
||||
// Check: The chat input is cleared (because it was submitted)
|
||||
await agent.check('The chat input field is empty');
|
||||
// Act: Use dev button to simulate user starting to speak
|
||||
await agent.act('Click the "Simulate Speech" button in the dev controls');
|
||||
|
||||
// Check: The finalized transcript appears as a user message
|
||||
await agent.check('The message "Hello world." appears in the chat list');
|
||||
// Check: Button shows speaking state
|
||||
await agent.check('The button text contains "Speaking"');
|
||||
|
||||
// Act: Add a phrase using the dev button
|
||||
await agent.act('Click the "Add Phrase" button in the dev controls');
|
||||
|
||||
// Check: A message bubble appears showing the transcript being spoken
|
||||
await agent.check('A message with text "You (speaking...)" is visible');
|
||||
await agent.check('The message contains the text "Test message"');
|
||||
|
||||
// Check: Button shows timing out state
|
||||
await agent.check('The button text contains "auto-submit"');
|
||||
|
||||
// Act: Trigger the timeout using dev button
|
||||
await agent.act('Click the "Trigger Timeout" button in the dev controls');
|
||||
|
||||
// Check: Button shows submitting or waiting state
|
||||
await agent.check('The button text contains "Submitting" or "Waiting for AI"');
|
||||
|
||||
// Check: The user message appears in the chat
|
||||
await agent.check('A message with text "Test message" appears in the chat history');
|
||||
|
||||
// Wait for AI response (this takes a few seconds)
|
||||
await agent.wait(10000);
|
||||
|
||||
// Check: AI message appears
|
||||
await agent.check('An AI message appears in the chat');
|
||||
|
||||
// Check: Button shows generating or playing TTS state
|
||||
await agent.check('The button text contains "Generating speech" or "AI is speaking"');
|
||||
|
||||
// Check: Skip button is visible during TTS
|
||||
await agent.check('A "Skip" button is visible');
|
||||
|
||||
// Act: Skip the AI audio
|
||||
await agent.act('Click the Skip button');
|
||||
|
||||
// Check: Returns to listening state
|
||||
await agent.check('The button shows "Listening... Start speaking"');
|
||||
|
||||
// Act: Stop voice mode
|
||||
await agent.act('Click the main voice button to stop');
|
||||
|
||||
// Check: Returns to idle state
|
||||
await agent.check('The button shows "Start Voice Conversation"');
|
||||
});
|
||||
|
||||
test('[Unhappy Path] Voice mode handles errors gracefully', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Act: Start voice mode
|
||||
await agent.act('Click the "Start Voice Conversation" button');
|
||||
|
||||
// Simulate an error scenario (e.g., microphone permission denied)
|
||||
// Note: In a real test, this would involve mocking the getUserMedia API to reject
|
||||
await agent.act('Simulate a microphone permission error');
|
||||
|
||||
// Check: Error message is displayed
|
||||
await agent.check('An error message is shown to the user');
|
||||
|
||||
// Check: Voice mode returns to idle state
|
||||
await agent.check('The button shows "Start Voice Conversation"');
|
||||
});
|
||||
|
||||
test('[Happy Path] Text input is disabled during voice mode', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Check: Text input is enabled initially
|
||||
await agent.check('The text input field "Or type your thoughts here..." is enabled');
|
||||
|
||||
// Act: Start voice mode
|
||||
await agent.act('Click the "Start Voice Conversation" button');
|
||||
|
||||
// Check: Text input is disabled
|
||||
await agent.check('The text input field is disabled');
|
||||
|
||||
// Act: Stop voice mode
|
||||
await agent.act('Click the main voice button to stop');
|
||||
|
||||
// Check: Text input is enabled again
|
||||
await agent.check('The text input field is enabled');
|
||||
});
|
||||
|
||||
test('[Happy Path] User can type a message while voice mode is idle', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Act: Type a message in the text input
|
||||
await agent.act('Type "This is a text message" into the text input field');
|
||||
|
||||
// Act: Submit the message
|
||||
await agent.act('Press Enter or click the Send button');
|
||||
|
||||
// Check: Message appears in chat
|
||||
await agent.check('The message "This is a text message" appears as a user message');
|
||||
|
||||
// Wait for AI response
|
||||
await agent.wait(5000);
|
||||
|
||||
// Check: AI responds
|
||||
await agent.check('An AI response appears in the chat');
|
||||
});
|
||||
|
||||
37
tests/magnitude/cache-success.mag.ts
Normal file
37
tests/magnitude/cache-success.mag.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Magnitude Test: Cache Success
|
||||
*
|
||||
* This test verifies that node publishing succeeds with full cache write,
|
||||
* not just a degraded state with warnings.
|
||||
*/
|
||||
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('Node publishes successfully with cache (no warnings)', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
|
||||
// Login
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.act('Fill in credentials and submit')
|
||||
.data({
|
||||
username: process.env.TEST_BLUESKY_USERNAME || 'test-user.bsky.social',
|
||||
password: process.env.TEST_BLUESKY_PASSWORD || 'test-password',
|
||||
});
|
||||
await agent.check('Logged in successfully');
|
||||
|
||||
// Start conversation
|
||||
await agent.act('Type "Test cache write success" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
|
||||
// Create and publish node
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page with draft');
|
||||
|
||||
await agent.act('Click "Publish Node"');
|
||||
|
||||
// CRITICAL: Should get green success notification, NOT yellow warning
|
||||
await agent.check('Success notification is GREEN (not yellow warning)');
|
||||
await agent.check('Notification says "Your node has been published to your Bluesky account"');
|
||||
await agent.check('Notification does NOT mention "cache update failed"');
|
||||
await agent.check('Notification does NOT mention "Advanced features may be unavailable"');
|
||||
});
|
||||
220
tests/magnitude/node-publishing.mag.ts
Normal file
220
tests/magnitude/node-publishing.mag.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Magnitude Tests: Node Publishing Flow
|
||||
*
|
||||
* Tests for the complete node creation, editing, and publishing workflow.
|
||||
* Covers both happy path and error scenarios.
|
||||
*/
|
||||
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
// ============================================================================
|
||||
// HAPPY PATH TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('User can publish a node from conversation', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
|
||||
// Step 1: Login with Bluesky
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.check('Redirected to Bluesky login page');
|
||||
|
||||
await agent.act('Fill in username and password')
|
||||
.data({
|
||||
username: process.env.TEST_BLUESKY_USERNAME || 'test-user.bsky.social',
|
||||
password: process.env.TEST_BLUESKY_PASSWORD || 'test-password',
|
||||
});
|
||||
|
||||
await agent.act('Click the login submit button');
|
||||
await agent.check('Redirected back to app and logged in');
|
||||
await agent.check('Chat interface is visible');
|
||||
|
||||
// Step 2: Start a conversation
|
||||
await agent.act('Type "Let\'s discuss the philosophy of decentralized social networks" into the chat input and press Enter');
|
||||
await agent.check('Message appears in chat');
|
||||
await agent.check('AI response appears');
|
||||
|
||||
// Step 3: Create node draft
|
||||
await agent.act('Click the "Create Node" button');
|
||||
await agent.check('Navigated to edit page');
|
||||
await agent.check('Title input has AI-generated content');
|
||||
await agent.check('Content textarea has AI-generated content');
|
||||
await agent.check('Conversation context is visible at the bottom');
|
||||
|
||||
// Step 4: Publish the node
|
||||
await agent.act('Click the "Publish Node" button');
|
||||
await agent.check('Success notification appears with "Node published!"');
|
||||
await agent.check('Returned to conversation view');
|
||||
});
|
||||
|
||||
test('User can edit node draft before publishing', async (agent) => {
|
||||
// Assumes user is already logged in from previous test
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Start conversation
|
||||
await agent.act('Type "Testing the edit flow" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
|
||||
// Create draft
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page with draft content');
|
||||
|
||||
// Edit the content
|
||||
await agent.act('Clear the title input and type "My Custom Title"');
|
||||
await agent.act('Modify the content textarea to add "This is my edited content."');
|
||||
|
||||
await agent.check('Title shows "My Custom Title"');
|
||||
await agent.check('Content includes "This is my edited content."');
|
||||
|
||||
// Publish
|
||||
await agent.act('Click "Publish Node"');
|
||||
await agent.check('Success notification appears');
|
||||
});
|
||||
|
||||
test('User can cancel node draft without publishing', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Start conversation
|
||||
await agent.act('Type "Test cancellation" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
|
||||
// Create draft
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
// Cancel instead of publishing
|
||||
await agent.act('Click the "Cancel" button');
|
||||
await agent.check('Returned to conversation view');
|
||||
await agent.check('Draft was not published'); // Verify no success notification
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UNHAPPY PATH TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('Cannot publish node without authentication', async (agent) => {
|
||||
// Open edit page directly without being logged in
|
||||
await agent.open('http://localhost:3000/edit');
|
||||
|
||||
await agent.check('Shows empty state message');
|
||||
await agent.check('Message says "No Node Draft"');
|
||||
await agent.check('Suggests to start a conversation');
|
||||
});
|
||||
|
||||
test('Cannot publish node with empty title', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Create draft
|
||||
await agent.act('Type "Test empty title validation" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
// Clear the title
|
||||
await agent.act('Clear the title input completely');
|
||||
|
||||
await agent.check('Publish button is disabled');
|
||||
});
|
||||
|
||||
test('Cannot publish node with empty content', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Create draft
|
||||
await agent.act('Type "Test empty content validation" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
// Clear the content
|
||||
await agent.act('Clear the content textarea completely');
|
||||
|
||||
await agent.check('Publish button is disabled');
|
||||
});
|
||||
|
||||
test('Shows error notification if publish fails', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Create draft
|
||||
await agent.act('Type "Test error handling" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
// Simulate network failure by disconnecting (this is a mock scenario)
|
||||
// In real test, this would require mocking the API
|
||||
await agent.act('Click "Publish Node"');
|
||||
|
||||
// If there's a network error, should see error notification
|
||||
// Note: This test may need to mock the fetch call to force an error
|
||||
await agent.check('Either success or error notification appears');
|
||||
});
|
||||
|
||||
test('Handles long content with truncation', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
// Create a very long message
|
||||
const longMessage = 'A'.repeat(500) + ' This is a test of long content truncation for Bluesky posts.';
|
||||
|
||||
await agent.act(`Type "${longMessage}" and press Enter`);
|
||||
await agent.check('AI responds');
|
||||
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
await agent.act('Click "Publish Node"');
|
||||
|
||||
// Should still publish successfully (with truncation)
|
||||
await agent.check('Success notification appears');
|
||||
await agent.check('May show warning about cache or truncation');
|
||||
});
|
||||
|
||||
test('Shows warning when cache fails but publish succeeds', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
await agent.act('Type "Test cache failure graceful degradation" and press Enter');
|
||||
await agent.check('AI responds');
|
||||
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('On edit page');
|
||||
|
||||
await agent.act('Click "Publish Node"');
|
||||
|
||||
// The system should succeed even if cache/embeddings fail
|
||||
await agent.check('Success notification appears');
|
||||
// May show yellow warning notification instead of green success
|
||||
await agent.check('Notification says "Node published"');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// INTEGRATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('Complete user journey: Login → Converse → Publish → View', async (agent) => {
|
||||
// Full end-to-end test
|
||||
await agent.open('http://localhost:3000');
|
||||
|
||||
// Login
|
||||
await agent.act('Login with Bluesky')
|
||||
.data({
|
||||
username: process.env.TEST_BLUESKY_USERNAME,
|
||||
password: process.env.TEST_BLUESKY_PASSWORD,
|
||||
});
|
||||
await agent.check('Logged in successfully');
|
||||
|
||||
// Have a meaningful conversation
|
||||
await agent.act('Type "I want to explore the concept of digital gardens" and send');
|
||||
await agent.check('AI responds with insights');
|
||||
|
||||
await agent.act('Reply with "How do digital gardens differ from blogs?"');
|
||||
await agent.check('AI provides detailed explanation');
|
||||
|
||||
// Create and publish
|
||||
await agent.act('Click "Create Node"');
|
||||
await agent.check('Draft generated from conversation');
|
||||
|
||||
await agent.act('Review the draft and click "Publish Node"');
|
||||
await agent.check('Node published successfully');
|
||||
|
||||
// Verify we can continue the conversation
|
||||
await agent.check('Back in conversation view');
|
||||
await agent.check('Can type new messages');
|
||||
});
|
||||
143
tests/voice-mode.spec.ts
Normal file
143
tests/voice-mode.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Voice Mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to chat page (should be authenticated via setup)
|
||||
await page.goto('/chat');
|
||||
await expect(page.getByText('Ponderants Interview')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should start voice conversation and display correct button text', async ({ page }) => {
|
||||
// Initial state - button should show "Start Voice Conversation"
|
||||
const voiceButton = page.getByRole('button', { name: /Start Voice Conversation/i });
|
||||
await expect(voiceButton).toBeVisible();
|
||||
|
||||
// Click to start voice mode
|
||||
await voiceButton.click();
|
||||
|
||||
// Button should transition to one of the active states
|
||||
// Could be "Generating speech..." if there's a greeting, or "Listening..." if no greeting
|
||||
await expect(page.getByRole('button', { name: /Generating speech|Listening|Checking for greeting/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test('should skip audio during generation and transition to listening', async ({ page }) => {
|
||||
// Start voice mode
|
||||
const voiceButton = page.getByRole('button', { name: /Start Voice Conversation/i });
|
||||
await voiceButton.click();
|
||||
|
||||
// Wait for generation or playing state
|
||||
await expect(page.getByRole('button', { name: /Generating speech|AI is speaking/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Skip button should be visible
|
||||
const skipButton = page.getByRole('button', { name: /Skip/i });
|
||||
await expect(skipButton).toBeVisible();
|
||||
|
||||
// Click skip
|
||||
await skipButton.click();
|
||||
|
||||
// Should transition to listening state
|
||||
await expect(page.getByRole('button', { name: /Listening/i })).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('should use test buttons to simulate full conversation flow', async ({ page }) => {
|
||||
// Start voice mode
|
||||
await page.getByRole('button', { name: /Start Voice Conversation/i }).click();
|
||||
|
||||
// Wait for initial state (could be checking, generating, or listening)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// If there's a skip button (greeting is playing), click it
|
||||
const skipButton = page.getByRole('button', { name: /Skip/i });
|
||||
if (await skipButton.isVisible()) {
|
||||
await skipButton.click();
|
||||
}
|
||||
|
||||
// Should eventually reach listening state
|
||||
await expect(page.getByRole('button', { name: /Listening/i })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// In development mode, test buttons should be visible
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
if (isDevelopment) {
|
||||
// Click "Simulate User Speech" test button
|
||||
const simulateSpeechButton = page.getByRole('button', { name: /Simulate Speech/i });
|
||||
await expect(simulateSpeechButton).toBeVisible();
|
||||
await simulateSpeechButton.click();
|
||||
|
||||
// Should transition to userSpeaking state
|
||||
await expect(page.getByRole('button', { name: /Speaking/i })).toBeVisible({ timeout: 2000 });
|
||||
|
||||
// Add a phrase using test button
|
||||
const addPhraseButton = page.getByRole('button', { name: /Add Phrase/i });
|
||||
await addPhraseButton.click();
|
||||
|
||||
// Should be in timingOut state
|
||||
await expect(page.getByRole('button', { name: /auto-submit/i })).toBeVisible({ timeout: 2000 });
|
||||
|
||||
// Trigger timeout using test button
|
||||
const triggerTimeoutButton = page.getByRole('button', { name: /Trigger Timeout/i });
|
||||
await triggerTimeoutButton.click();
|
||||
|
||||
// Should submit and wait for AI
|
||||
await expect(page.getByRole('button', { name: /Submitting|Waiting for AI/i })).toBeVisible({
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
// Wait for AI response (this will take a few seconds)
|
||||
await expect(page.getByRole('button', { name: /Generating speech|AI is speaking/i })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Skip the AI audio
|
||||
const skipAudioButton = page.getByRole('button', { name: /Skip/i });
|
||||
if (await skipAudioButton.isVisible()) {
|
||||
await skipAudioButton.click();
|
||||
}
|
||||
|
||||
// Should return to listening
|
||||
await expect(page.getByRole('button', { name: /Listening/i })).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should stop voice mode and return to idle', async ({ page }) => {
|
||||
// Start voice mode
|
||||
const voiceButton = page.getByRole('button', { name: /Start Voice Conversation/i });
|
||||
await voiceButton.click();
|
||||
|
||||
// Wait for active state
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click the button again to stop
|
||||
await page.getByRole('button', { name: /Listening|Speaking|Generating|AI is speaking/i }).click();
|
||||
|
||||
// Should return to idle state
|
||||
await expect(page.getByRole('button', { name: /Start Voice Conversation/i })).toBeVisible({
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
test('should disable text input while voice mode is active', async ({ page }) => {
|
||||
const textInput = page.getByPlaceholder(/type your thoughts here/i);
|
||||
|
||||
// Text input should be enabled initially
|
||||
await expect(textInput).toBeEnabled();
|
||||
|
||||
// Start voice mode
|
||||
await page.getByRole('button', { name: /Start Voice Conversation/i }).click();
|
||||
|
||||
// Wait for voice mode to activate
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Text input should be disabled
|
||||
await expect(textInput).toBeDisabled();
|
||||
|
||||
// Stop voice mode
|
||||
await page.getByRole('button', { name: /Listening|Speaking|Generating|AI is speaking/i }).click();
|
||||
|
||||
// Text input should be enabled again
|
||||
await expect(textInput).toBeEnabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user