chore(tooling): 🔧 Update build and linting tooling configurations
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5ba20bba1f
commit
49e7d5d4b7
9 changed files with 420 additions and 0 deletions
46
@tooling/e2e/e2e/app.e2e.ts
Normal file
46
@tooling/e2e/e2e/app.e2e.ts
Normal file
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
92
@tooling/e2e/e2e/chat.e2e.ts
Normal file
92
@tooling/e2e/e2e/chat.e2e.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
103
@tooling/e2e/e2e/mocks.ts
Normal file
103
@tooling/e2e/e2e/mocks.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
40
@tooling/e2e/e2e/toast.e2e.ts
Normal file
40
@tooling/e2e/e2e/toast.e2e.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
16
@tooling/e2e/package.json
Normal file
16
@tooling/e2e/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
90
@tooling/e2e/playwright-report/index.html
Normal file
90
@tooling/e2e/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
17
@tooling/e2e/playwright.config.ts
Normal file
17
@tooling/e2e/playwright.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
4
@tooling/e2e/test-results/companion-web/.last-run.json
Normal file
4
@tooling/e2e/test-results/companion-web/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
12
@tooling/e2e/tsconfig.json
Normal file
12
@tooling/e2e/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue