chore(tooling): 🔧 Update build and linting tooling configurations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-02 21:44:55 -07:00
parent 5ba20bba1f
commit 49e7d5d4b7
9 changed files with 420 additions and 0 deletions

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

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

View 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
View 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"
}
}

File diff suppressed because one or more lines are too long

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

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["**/*.ts"]
}