managed sessions only. allow for rename/delete

This commit is contained in:
2026-01-19 19:34:58 -05:00
parent e2048d8b69
commit 313ac44337
32 changed files with 1759 additions and 331 deletions
+55
View File
@@ -0,0 +1,55 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Spiceflow E2E tests - Playwright-based end-to-end tests for the Spiceflow AI Session Orchestration PWA. Tests run against both the Clojure backend and SvelteKit frontend.
## Commands
```bash
# Run all tests (starts both servers automatically)
npm test
# Run with visible browser
npm run test:headed
# Run with Playwright UI mode
npm run test:ui
```
## Architecture
The e2e setup automatically manages both servers:
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3000) and frontend (port 5173)
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
## Test Database
E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the main database.
## Writing Tests
```typescript
import { test, expect } from '@playwright/test';
test('example', async ({ page, request }) => {
// Direct API call
const response = await request.get('http://localhost:3000/api/sessions');
// Browser interaction
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
```
## Parent Project
This is part of the Spiceflow monorepo. See `../CLAUDE.md` for full project documentation including:
- `../server/` - Clojure backend (Ring/Reitit, SQLite)
- `../client/` - SvelteKit PWA frontend
+12
View File
@@ -0,0 +1,12 @@
import { startServers } from './server-utils.js';
export default async function globalSetup() {
// Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
return;
}
console.log('\n=== Starting E2E Test Environment ===\n');
await startServers(3000, 5173);
console.log('\n=== E2E Test Environment Ready ===\n');
}
+12
View File
@@ -0,0 +1,12 @@
import { stopServers } from './server-utils.js';
export default async function globalTeardown() {
// Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n');
return;
}
console.log('\n=== Stopping E2E Test Environment ===\n');
stopServers();
console.log('\n=== E2E Test Environment Stopped ===\n');
}
+302
View File
@@ -0,0 +1,302 @@
{
"name": "e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "e2e",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^25.0.9",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "spiceflow-e2e",
"version": "1.0.0",
"description": "E2E tests for Spiceflow",
"type": "module",
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^25.0.9",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'list',
timeout: 30000,
use: {
baseURL: 'https://localhost:5173',
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
});
+156
View File
@@ -0,0 +1,156 @@
import { spawn, ChildProcess, execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT_DIR = resolve(__dirname, '..');
const SERVER_DIR = resolve(ROOT_DIR, 'server');
const CLIENT_DIR = resolve(ROOT_DIR, 'client');
export interface ServerProcesses {
backend: ChildProcess | null;
frontend: ChildProcess | null;
}
let processes: ServerProcesses = {
backend: null,
frontend: null,
};
function waitForServer(url: string, timeout = 30000): Promise<void> {
const start = Date.now();
// Allow self-signed certificates for HTTPS
const originalTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
if (url.startsWith('https://')) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
return new Promise((resolve, reject) => {
const cleanup = () => {
if (originalTlsReject !== undefined) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsReject;
} else {
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
}
};
const check = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
// Add small delay to ensure server is fully ready
await new Promise((r) => setTimeout(r, 500));
cleanup();
resolve();
return;
}
} catch {
// Server not ready yet
}
if (Date.now() - start > timeout) {
cleanup();
reject(new Error(`Server at ${url} did not start within ${timeout}ms`));
return;
}
setTimeout(check, 500);
};
check();
});
}
export async function startBackend(port = 3000): Promise<ChildProcess> {
console.log('Starting backend server...');
// Use a test database to avoid polluting the main one
const testDbPath = resolve(SERVER_DIR, 'test-e2e.db');
// Remove old test database
try {
execSync(`rm -f ${testDbPath}`);
} catch {
// Ignore if doesn't exist
}
const backend = spawn('clj', ['-M:run'], {
cwd: SERVER_DIR,
env: {
...process.env,
SPICEFLOW_PORT: String(port),
SPICEFLOW_DB: testDbPath,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
backend.stdout?.on('data', (data) => {
console.log(`[backend] ${data.toString().trim()}`);
});
backend.stderr?.on('data', (data) => {
console.error(`[backend] ${data.toString().trim()}`);
});
processes.backend = backend;
await waitForServer(`http://localhost:${port}/api/health`);
console.log('Backend server ready');
return backend;
}
export async function startFrontend(port = 5173, backendPort = 3000): Promise<ChildProcess> {
console.log('Starting frontend server...');
const frontend = spawn('npm', ['run', 'dev', '--', '--port', String(port)], {
cwd: CLIENT_DIR,
env: {
...process.env,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
frontend.stdout?.on('data', (data) => {
console.log(`[frontend] ${data.toString().trim()}`);
});
frontend.stderr?.on('data', (data) => {
console.error(`[frontend] ${data.toString().trim()}`);
});
processes.frontend = frontend;
await waitForServer(`https://localhost:${port}`);
console.log('Frontend server ready');
return frontend;
}
export async function startServers(backendPort = 3000, frontendPort = 5173): Promise<ServerProcesses> {
await startBackend(backendPort);
await startFrontend(frontendPort, backendPort);
return processes;
}
export function stopServers(): void {
console.log('Stopping servers...');
if (processes.frontend) {
processes.frontend.kill('SIGTERM');
processes.frontend = null;
}
if (processes.backend) {
processes.backend.kill('SIGTERM');
processes.backend = null;
}
console.log('Servers stopped');
}
export function getProcesses(): ServerProcesses {
return processes;
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
test.describe('Basic E2E Tests', () => {
test('backend health check', async ({ request }) => {
const response = await request.get('http://localhost:3000/api/health');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('ok');
expect(body.service).toBe('spiceflow');
});
test('frontend loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
});
test('sessions list loads empty', async ({ request }) => {
const response = await request.get('http://localhost:3000/api/sessions');
expect(response.ok()).toBeTruthy();
const sessions = await response.json();
expect(Array.isArray(sessions)).toBeTruthy();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { test, expect } from '@playwright/test';
test.describe('Permissions Workflow', () => {
test('permission approval allows file creation and reading', async ({ page }) => {
// Increase timeout for this test since it involves real Claude interaction
test.setTimeout(180000);
// Enable console logging for debugging
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log WebSocket frames for debugging
page.on('websocket', (ws) => {
console.log(`[WebSocket] Connected to ${ws.url()}`);
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Create a new session
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 4. Wait for the page to load
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('text=No messages yet')).toBeVisible();
// 5. Send message asking Claude to create foo.md with a haiku
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill(
'Create a file called foo.md containing a haiku about software testing. Title it "My Haiku" at the top. Just create the file, no other commentary needed.'
);
const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 6. Verify user message appears
await expect(
page.locator('text=Create a file called foo.md')
).toBeVisible();
console.log('[Test] User message displayed');
// 6b. Verify thinking indicator appears immediately
const assistantBubble = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
}).first();
await expect(assistantBubble).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared immediately');
// 7. Wait for permission request UI to appear
const permissionUI = page.locator('text=Claude needs permission');
await expect(permissionUI).toBeVisible({ timeout: 60000 });
console.log('[Test] Permission request UI appeared');
// 8. Verify the permission shows Write tool for foo.md
const permissionDescription = page.locator('li.font-mono').filter({
hasText: /Write.*foo\.md|create.*foo\.md/i
}).first();
await expect(permissionDescription).toBeVisible();
console.log('[Test] Permission shows foo.md file creation');
// 9. Click Accept button
const acceptButton = page.locator('button:has-text("Accept")');
await expect(acceptButton).toBeVisible();
await acceptButton.click();
console.log('[Test] Clicked Accept button');
// 10. Wait for permission UI to disappear
await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
console.log('[Test] Permission UI disappeared');
// 11. Wait for streaming to complete after permission granted
await page.waitForTimeout(2000);
const bouncingDots = page.locator('.animate-bounce');
const pulsingCursor = page.locator('.animate-pulse');
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] First streaming complete');
// Count current assistant messages before sending new request
const assistantMessages = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
});
const messageCountBefore = await assistantMessages.count();
console.log('[Test] Assistant message count before read request:', messageCountBefore);
// 12. Now ask Claude to read the file back to verify it was created
await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
await sendButton.click();
console.log('[Test] Asked Claude to read the file');
// 13. Wait for a NEW assistant message to appear
await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 });
console.log('[Test] New assistant message appeared');
// Wait for streaming to complete on the new message
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] Second streaming complete');
// 14. Verify the response contains "My Haiku" - confirming file was created and read
const lastAssistantMessage = assistantMessages.last();
const responseText = await lastAssistantMessage.locator('.font-mono').textContent();
console.log('[Test] Claude read back:', responseText);
// The response should contain "My Haiku" which we asked Claude to title the file
expect(responseText).toBeTruthy();
expect(responseText).toContain('My Haiku');
console.log('[Test] Successfully verified "My Haiku" in Claude response');
});
});
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
test.describe('Chat Workflow', () => {
test('create new chat and send message to Claude', async ({ page }) => {
// Enable console logging to debug WebSocket issues
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log WebSocket frames
page.on('websocket', (ws) => {
console.log(`[WebSocket] Connected to ${ws.url()}`);
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
ws.on('close', () => console.log('[WebSocket] Closed'));
});
// Log network requests to /api
page.on('request', (request) => {
if (request.url().includes('/api')) {
console.log(`[Request] ${request.method()} ${request.url()}`);
}
});
page.on('response', (response) => {
if (response.url().includes('/api')) {
console.log(`[Response] ${response.status()} ${response.url()}`);
}
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to create a new session
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 4. Wait for the page to load (no loading spinner)
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
// 5. Verify we see the empty message state
await expect(page.locator('text=No messages yet')).toBeVisible();
// 6. Type a message in the textarea
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill('say hi. respond in a single concise sentence');
// 7. Click the send button
const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 8. Verify user message appears immediately (optimistic update)
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
console.log('[Test] User message displayed');
// 9. Verify thinking indicator appears immediately after sending
// The assistant bubble with bouncing dots should show right away (isThinking state)
const assistantMessage = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
}).last();
// Thinking indicator should appear almost immediately (within 2 seconds)
await expect(assistantMessage).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared immediately');
// Verify bouncing dots are present (thinking state)
const bouncingDotsInAssistant = assistantMessage.locator('.animate-bounce');
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Bouncing dots visible in thinking state');
// 10. Wait for streaming to complete - progress indicator should disappear
// The streaming indicator has animate-bounce dots and animate-pulse cursor
// Note: With fast responses, the indicator may appear and disappear quickly,
// so we just verify it's gone after the response is visible
const bouncingDots = page.locator('.animate-bounce');
const pulsingCursor = page.locator('.animate-pulse');
// Wait for streaming indicators to disappear (they should be gone after message-stop)
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
console.log('[Test] Streaming complete - progress indicator disappeared');
// 11. Verify the response contains some text content
const responseText = await assistantMessage.locator('.font-mono').textContent();
console.log('[Test] Assistant response text:', responseText);
expect(responseText).toBeTruthy();
expect(responseText!.length).toBeGreaterThan(0);
// 12. Verify working directory indicator appears
// The working directory should be captured from the init event and displayed
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
const workingDirText = await workingDirIndicator.textContent();
console.log('[Test] Working directory displayed:', workingDirText);
expect(workingDirText).toMatch(/^\//); // Should start with /
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}