diff --git a/@tooling/e2e/e2e/app.e2e.ts b/@tooling/e2e/e2e/app.e2e.ts new file mode 100644 index 0000000..97d12ed --- /dev/null +++ b/@tooling/e2e/e2e/app.e2e.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; +import { setupApiMocks } from './mocks'; + +test.describe('App shell', () => { + test.beforeEach(async ({ page }) => { + await setupApiMocks(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('renders the Companion heading', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Companion' })).toBeVisible(); + }); + + test('shows connection indicator', async ({ page }) => { + // Connection dot is present (disconnected without voice WebSocket) + const dot = page.locator('[title="disconnected"], [title="connected"], [title="error"]'); + await expect(dot).toBeVisible(); + }); + + test('renders the mic button in idle state', async ({ page }) => { + const mic = page.getByRole('button', { name: 'Hold to speak' }); + await expect(mic).toBeVisible(); + await expect(mic).not.toBeDisabled(); + }); + + test('renders the message input', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + await expect(input).toBeVisible(); + await expect(input).toHaveAttribute('placeholder', 'Type a message…'); + }); + + test('send button is initially inactive', async ({ page }) => { + const send = page.getByRole('button', { name: 'Send message' }); + await expect(send).toBeVisible(); + await expect(send).toBeDisabled(); + }); + + test('page has dark background', async ({ page }) => { + const bg = await page.evaluate(() => + getComputedStyle(document.body).backgroundColor, + ); + // Background is #0a0a0f in rgb + expect(bg).toBe('rgb(10, 10, 15)'); + }); +}); diff --git a/@tooling/e2e/e2e/chat.e2e.ts b/@tooling/e2e/e2e/chat.e2e.ts new file mode 100644 index 0000000..375e3a3 --- /dev/null +++ b/@tooling/e2e/e2e/chat.e2e.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; +import { setupApiMocks, setupChatErrorMock } from './mocks'; + +test.describe('Text chat', () => { + test.beforeEach(async ({ page }) => { + await setupApiMocks(page, { + chatSegments: [ + { text: 'Hello! ', emotion: 'friendly' }, + { text: 'How can I help you?', emotion: 'neutral' }, + ], + }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('typing enables the send button', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + const send = page.getByRole('button', { name: 'Send message' }); + + await input.fill('Hello'); + await expect(send).not.toBeDisabled(); + }); + + test('send button disables when input is empty', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + const send = page.getByRole('button', { name: 'Send message' }); + + await input.fill('text'); + await input.fill(''); + await expect(send).toBeDisabled(); + }); + + test('sending a message shows it in the chat', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + + await input.fill('What can you help me with?'); + await input.press('Enter'); + + await expect(page.getByText('What can you help me with?')).toBeVisible(); + }); + + test('input clears after sending', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + + await input.fill('test message'); + await input.press('Enter'); + + await expect(input).toHaveValue(''); + }); + + test('streaming response appears in chat', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + + await input.fill('Hello'); + await input.press('Enter'); + + // Wait for the streamed response text to appear + await expect(page.getByText('Hello! ')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('How can I help you?')).toBeVisible({ timeout: 5000 }); + }); + + test('Shift+Enter adds a newline instead of sending', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + + await input.fill('line 1'); + await input.press('Shift+Enter'); + await input.type('line 2'); + + // Input still has content (not sent) + const value = await input.inputValue(); + expect(value).toContain('line 1'); + expect(value).toContain('line 2'); + }); +}); + +test.describe('Chat errors', () => { + test.beforeEach(async ({ page }) => { + await setupChatErrorMock(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('shows error in chat when API returns 500', async ({ page }) => { + const input = page.getByRole('textbox', { name: 'Message input' }); + + await input.fill('Hello'); + await input.press('Enter'); + + // Error message appears inline in the chat area + await expect(page.getByText(/Chat request failed|Could not send/)).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/@tooling/e2e/e2e/mocks.ts b/@tooling/e2e/e2e/mocks.ts new file mode 100644 index 0000000..7a42370 --- /dev/null +++ b/@tooling/e2e/e2e/mocks.ts @@ -0,0 +1,103 @@ +import type { Page, Route } from '@playwright/test'; + +export const E2E_SESSION_ID = 'e2e-session-abc123'; + +/** + * Build an SSE body string from a list of segment texts. + * Produces the format the companion SSE parser expects. + */ +function buildSseBody(segments: Array<{ text: string; emotion?: string }>): string { + const events = segments + .map( + (seg, i) => + `event: segment\ndata: ${JSON.stringify({ part_index: i, text: seg.text, emotion: seg.emotion ?? 'neutral' })}\n`, + ) + .join('\n'); + return `${events}\ndata: [DONE]\n\n`; +} + +async function fulfillOrContinue(route: Route, status: number, body?: { contentType: string; body: string }): Promise { + try { + if (body) { + await route.fulfill({ status, contentType: body.contentType, body: body.body }); + } else { + await route.fulfill({ status }); + } + } catch { + // Route may already be handled (navigation cancelled) + } +} + +/** + * Mock the companion API for a clean test session. + * + * Sets up: + * - POST /api/session → { session_id: E2E_SESSION_ID } + * - POST /api/chat → SSE segments from the provided response text + * - GET /socket.io/** → 404 (voice WebSocket disabled; app shows 'disconnected') + */ +export async function setupApiMocks( + page: Page, + opts: { chatSegments?: Array<{ text: string; emotion?: string }> } = {}, +): Promise { + const segments = opts.chatSegments ?? [ + { text: 'Hello! How can I help you today?', emotion: 'friendly' }, + ]; + + await page.route('**/api/session', async (route) => { + if (route.request().method() !== 'POST') { await route.continue(); return; } + await fulfillOrContinue(route, 200, { + contentType: 'application/json', + body: JSON.stringify({ session_id: E2E_SESSION_ID }), + }); + }); + + await page.route('**/api/chat', async (route) => { + if (route.request().method() !== 'POST') { await route.continue(); return; } + await fulfillOrContinue(route, 200, { + contentType: 'text/event-stream', + body: buildSseBody(segments), + }); + }); + + // Block Socket.IO — voice WebSocket is not under test here + await page.route('**/socket.io/**', async (route) => { + await fulfillOrContinue(route, 404); + }); +} + +/** + * Mock a session creation failure (500 from server). + */ +export async function setupSessionErrorMock(page: Page): Promise { + await page.route('**/api/session', async (route) => { + if (route.request().method() !== 'POST') { await route.continue(); return; } + await fulfillOrContinue(route, 500); + }); + + await page.route('**/socket.io/**', async (route) => { + await fulfillOrContinue(route, 404); + }); +} + +/** + * Mock a chat request that returns a server error. + */ +export async function setupChatErrorMock(page: Page): Promise { + await page.route('**/api/session', async (route) => { + if (route.request().method() !== 'POST') { await route.continue(); return; } + await fulfillOrContinue(route, 200, { + contentType: 'application/json', + body: JSON.stringify({ session_id: E2E_SESSION_ID }), + }); + }); + + await page.route('**/api/chat', async (route) => { + if (route.request().method() !== 'POST') { await route.continue(); return; } + await fulfillOrContinue(route, 500); + }); + + await page.route('**/socket.io/**', async (route) => { + await fulfillOrContinue(route, 404); + }); +} diff --git a/@tooling/e2e/e2e/toast.e2e.ts b/@tooling/e2e/e2e/toast.e2e.ts new file mode 100644 index 0000000..1efee6c --- /dev/null +++ b/@tooling/e2e/e2e/toast.e2e.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { setupSessionErrorMock } from './mocks'; + +test.describe('Toast notifications', () => { + test('shows error toast when session creation fails', async ({ page }) => { + await setupSessionErrorMock(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Toast with a connection/session error message appears. + // In React dev StrictMode effects may fire twice — use first(). + const toast = page.getByText(/Could not connect|Session creation failed|POST \/session failed/).first(); + await expect(toast).toBeVisible({ timeout: 8000 }); + }); + + test('toast auto-dismisses after 5 seconds', async ({ page }) => { + await setupSessionErrorMock(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const toast = page.getByText(/Could not connect|Session creation failed/).first(); + await expect(toast).toBeVisible({ timeout: 8000 }); + + // Wait for auto-dismiss (5s) plus 2s buffer + await expect(toast).not.toBeVisible({ timeout: 8000 }); + }); + + test('toast has drag="x" attribute for swipe-to-dismiss', async ({ page }) => { + await setupSessionErrorMock(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const toast = page.getByText(/Could not connect|Session creation failed/).first(); + await expect(toast).toBeVisible({ timeout: 8000 }); + + // Framer-motion sets draggable="false" on the host element when drag="x" is active + const draggableEl = toast.locator('xpath=ancestor-or-self::div[@draggable]').first(); + await expect(draggableEl).toHaveAttribute('draggable', 'false'); + }); +}); diff --git a/@tooling/e2e/package.json b/@tooling/e2e/package.json new file mode 100644 index 0000000..d3ee09b --- /dev/null +++ b/@tooling/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@companion/e2e", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "report": "playwright show-report test-results/companion-web/html" + }, + "devDependencies": { + "@lilith/playwright-e2e-docker": "^2.0.2", + "@playwright/test": "^1.50.0" + } +} diff --git a/@tooling/e2e/playwright-report/index.html b/@tooling/e2e/playwright-report/index.html new file mode 100644 index 0000000..1b3ba69 --- /dev/null +++ b/@tooling/e2e/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/@tooling/e2e/playwright.config.ts b/@tooling/e2e/playwright.config.ts new file mode 100644 index 0000000..61855e1 --- /dev/null +++ b/@tooling/e2e/playwright.config.ts @@ -0,0 +1,17 @@ +import { createPlaywrightConfig } from '@lilith/playwright-e2e-docker'; + +export default createPlaywrightConfig({ + testDir: './e2e', + appName: 'companion-web', + devicePreset: 'mobile', + fullyParallel: true, + retries: 1, + baseURL: 'http://localhost:5850', + webServer: { + // Run from monorepo root (two levels up from @tooling/e2e) + command: 'pnpm -C ../.. --filter @companion/web dev', + port: 5850, + reuseExistingServer: true, + timeout: 60000, + }, +}); diff --git a/@tooling/e2e/test-results/companion-web/.last-run.json b/@tooling/e2e/test-results/companion-web/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/@tooling/e2e/test-results/companion-web/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/@tooling/e2e/tsconfig.json b/@tooling/e2e/tsconfig.json new file mode 100644 index 0000000..dfeadb5 --- /dev/null +++ b/@tooling/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["**/*.ts"] +}