From c2ab0c6a6fa92f28e768dd85c9c0484cb4439366 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 11 Feb 2026 17:29:53 +0100 Subject: [PATCH] chore: bump version to 0.1478 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 +- README.md | 123 +-- package.json | 2 +- test/async-utilities.test.ts | 759 ----------------- test/browser-agent.test.ts | 487 ----------- test/browser-comparison.test.ts | 624 -------------- test/browser-e2e.test.ts | 416 --------- test/browser-playwright.test.ts | 194 ----- test/browser-puppeteer.test.ts | 445 ---------- .../e2e/workflows/mobile-comprehensive.e2e.ts | 530 ------------ test/e2e/workflows/mobile-edge-cases.e2e.ts | 630 -------------- test/event-emitter.test.ts | 775 ----------------- test/final-coverage.test.ts | 408 --------- test/mobile-settings-test.ts | 182 ---- test/timing-utilities.test.ts | 802 ------------------ 15 files changed, 79 insertions(+), 6305 deletions(-) delete mode 100644 test/async-utilities.test.ts delete mode 100644 test/browser-agent.test.ts delete mode 100644 test/browser-comparison.test.ts delete mode 100644 test/browser-e2e.test.ts delete mode 100644 test/browser-playwright.test.ts delete mode 100644 test/browser-puppeteer.test.ts delete mode 100644 test/e2e/workflows/mobile-comprehensive.e2e.ts delete mode 100644 test/e2e/workflows/mobile-edge-cases.e2e.ts delete mode 100644 test/event-emitter.test.ts delete mode 100644 test/final-coverage.test.ts delete mode 100644 test/mobile-settings-test.ts delete mode 100644 test/timing-utilities.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 09564094..99fcce3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1477 (must match `package.json` for npm publish) +**Version**: 0.1478 (must match `package.json` for npm publish) ## Project Overview @@ -78,9 +78,9 @@ journalctl --user -u claudeman-web -f ## Import Conventions -- **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, debounce } from './utils'` +- **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, stripAnsi } from './utils'` - **Types**: Use type imports: `import type { SessionState } from './types'` -- **Config**: Import from specific files: `import { BUFFER_LIMITS } from './config/buffer-limits'` +- **Config**: Import from specific files: `import { MAX_TERMINAL_BUFFER_SIZE } from './config/buffer-limits'` ## Architecture @@ -113,6 +113,7 @@ journalctl --user -u claudeman-web -f | `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | | `src/cli.ts` | Command-line interface handlers | | `src/web/server.ts` | Fastify REST API + SSE at `/api/events` | +| `src/web/schemas.ts` | Zod v4 validation schemas for API request bodies | | `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows | | `src/types.ts` | All TypeScript interfaces | diff --git a/README.md b/README.md index 90d5fa46..dd12542c 100644 --- a/README.md +++ b/README.md @@ -348,51 +348,53 @@ claudeman web --- -## Mobile Access (Termius/SSH) +## Mobile — The Best Way to Use Claude Code on Your Phone -**Claudeman Screens** (`sc`) is a mobile-friendly screen session chooser, optimized for Termius on iPhone. +**Forget terminal apps.** Claudeman's web UI gives you a full Claude Code dashboard on your phone — real-time terminals, swipe navigation, smart keyboard handling, and quick commands. Just open your browser. + +

+ Mobile Dashboard +    + Mobile Keyboard +    + Mobile Toolbar +

+ +### Why It's Better Than Any Terminal App + +| Terminal Apps | Claudeman Mobile | +|:--------------|:-----------------| +| Tiny text, no context | Full xterm.js terminal, responsive layout | +| No session management | Swipe between sessions, tab badges show status | +| No notifications | Push alerts when Claude needs approval or goes idle | +| Manual reconnect after drops | GNU Screen persistence — sessions survive anything | +| No agent visibility | See background agents working in real-time | +| No token/cost tracking | Live token counts and cost per session | +| Copy-paste slash commands | One-tap `/init`, `/clear`, `/compact` buttons | + +### Setup ```bash -sc # Interactive chooser -sc 2 # Quick attach to session 2 -sc -l # List sessions -sc -h # Help +# Start with HTTPS for mobile access over your network +claudeman web --https + +# Open on your phone: https://:3000 ``` -**Features:** -- Single-digit selection (1-9) for fast thumb typing -- Color-coded status indicators (attached/detached/respawn) -- Token count display -- Session names from Claudeman state -- Pagination for many sessions -- Auto-refresh every 60 seconds +> **Tip:** Local access on `localhost` works over plain HTTP. Use `--https` when accessing from another device on your network. -**Indicators:** -| Symbol | Meaning | -|--------|---------| -| `*` / `●` | Attached (someone connected) | -| `-` / `○` | Detached (available) | -| `R` | Respawn enabled | -| `45k` | Token count | +### Touch-Optimized Interface -**Tip:** Detach from a screen with `Ctrl+A D` - ---- - -## Mobile UI - -Claudeman's web interface is fully optimized for mobile browsers (iPhone, Android). Access the dashboard from any device on your network using `--https` mode. - -### Features - -| Feature | Description | +| Feature | How It Works | |---------|-------------| -| **Fixed Toolbar** | Run Claude / Run Shell buttons fixed at bottom, above safe area | -| **Smart Keyboard Handling** | Toolbar and terminal content move up when virtual keyboard appears | -| **Swipe Navigation** | Swipe left/right on terminal to switch between sessions | -| **Quick Commands** | Keyboard accessory bar with /init, /clear, /compact buttons | -| **Confirmation Dialogs** | Accidental taps on commands prompt for confirmation | -| **Touch Targets** | 44px minimum touch targets per iOS Human Interface Guidelines | +| **Swipe Navigation** | Swipe left/right on the terminal to switch sessions (80px threshold, 300ms) | +| **Keyboard Accessory Bar** | `/init`, `/clear`, `/compact` quick-action buttons appear above the virtual keyboard | +| **Smart Keyboard Handling** | Toolbar and terminal shift up when the keyboard opens (uses `visualViewport` API) | +| **Safe Area Support** | Respects iPhone notch and home indicator via `env(safe-area-inset-*)` | +| **44px Touch Targets** | All buttons meet iOS Human Interface Guidelines minimum sizes | +| **Confirmation Dialogs** | Destructive commands require a tap to confirm — no accidental `/clear` | +| **Bottom Sheet Case Picker** | Slide-up modal replaces the desktop dropdown for case selection | +| **Native Momentum Scrolling** | Terminal uses `-webkit-overflow-scrolling: touch` for buttery scroll | ### Keyboard Accessory Bar @@ -400,23 +402,46 @@ When the virtual keyboard is open, a quick-action bar appears above the toolbar: | Button | Action | Confirmation | |--------|--------|--------------| -| `/init` | Send /init to reinitialize context | "Run /init command?" | -| `/clear` | Send /clear to clear conversation | "Clear conversation history?" | -| `/compact` | Send /compact to summarize context | "Compact context?" | +| `/init` | Reinitialize context | "Run /init command?" | +| `/clear` | Clear conversation | "Clear conversation history?" | +| `/compact` | Summarize context | "Compact context?" | | `⌄` | Dismiss keyboard | — | -### Swipe Gestures +### Mobile-Optimized Layout -- **Swipe left** → Next session -- **Swipe right** → Previous session +The mobile UI strips away desktop complexity to keep things fast and focused: -Gestures require 80px minimum horizontal movement within 300ms. - -### Mobile-Only Simplifications - -- Case selector hidden (use desktop for case management) -- Monitor and subagent panels hidden by default +- **Full-width terminal** with scrollable session tabs +- Case management via bottom sheet (not desktop dropdown) +- Monitor/subagent panels hidden by default (toggle in settings) - Full-screen modals for settings +- Compact typography and spacing + +--- + +### Claudeman Screens (`sc`) — Mobile SSH Alternative + +If you prefer SSH (via Termius, Blink, etc.), the `sc` command is a thumb-friendly screen session chooser: + +```bash +sc # Interactive chooser +sc 2 # Quick attach to session 2 +sc -l # List sessions +``` + +- **Single-digit selection** (1-9) — fast thumb typing on phone keyboards +- **Color-coded status** — green for attached, gray for detached, `R` for respawn +- **Token counts** displayed per session +- **Pagination** for many sessions, auto-refresh every 60s + +| Symbol | Meaning | +|--------|---------| +| `●` / `*` | Attached (someone connected) | +| `○` / `-` | Detached (available) | +| `R` | Respawn enabled | +| `45k` | Token count | + +**Detach:** `Ctrl+A D` --- diff --git a/package.json b/package.json index 5b1de108..628c5df6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1477", + "version": "0.1478", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/test/async-utilities.test.ts b/test/async-utilities.test.ts deleted file mode 100644 index 0c820770..00000000 --- a/test/async-utilities.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -/** - * @fileoverview Tests for async utility patterns - * - * Tests Promise utilities, async iterators, concurrent execution, - * and other async patterns used in the application. - */ - -import { describe, it, expect, vi } from 'vitest'; - -describe('Async Utilities', () => { - describe('Promise.all Patterns', () => { - it('should resolve all promises', async () => { - const results = await Promise.all([ - Promise.resolve(1), - Promise.resolve(2), - Promise.resolve(3), - ]); - expect(results).toEqual([1, 2, 3]); - }); - - it('should reject on any failure', async () => { - const promise = Promise.all([ - Promise.resolve(1), - Promise.reject(new Error('fail')), - Promise.resolve(3), - ]); - await expect(promise).rejects.toThrow('fail'); - }); - - it('should handle empty array', async () => { - const results = await Promise.all([]); - expect(results).toEqual([]); - }); - }); - - describe('Promise.allSettled Patterns', () => { - it('should return all results', async () => { - const results = await Promise.allSettled([ - Promise.resolve(1), - Promise.reject(new Error('fail')), - Promise.resolve(3), - ]); - - expect(results[0]).toEqual({ status: 'fulfilled', value: 1 }); - expect(results[1]).toMatchObject({ status: 'rejected' }); - expect(results[2]).toEqual({ status: 'fulfilled', value: 3 }); - }); - - it('should separate successful and failed results', async () => { - const results = await Promise.allSettled([ - Promise.resolve(1), - Promise.reject(new Error('fail')), - Promise.resolve(3), - ]); - - const successes = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') - .map(r => r.value); - const failures = results - .filter((r): r is PromiseRejectedResult => r.status === 'rejected') - .map(r => r.reason); - - expect(successes).toEqual([1, 3]); - expect(failures).toHaveLength(1); - }); - }); - - describe('Promise.race Patterns', () => { - it('should return first resolved', async () => { - const result = await Promise.race([ - new Promise(resolve => setTimeout(() => resolve('slow'), 100)), - Promise.resolve('fast'), - ]); - expect(result).toBe('fast'); - }); - - it('should reject if first is rejected', async () => { - const promise = Promise.race([ - Promise.reject(new Error('first')), - new Promise(resolve => setTimeout(() => resolve('slow'), 100)), - ]); - await expect(promise).rejects.toThrow('first'); - }); - }); - - describe('Promise.any Patterns', () => { - it('should return first fulfilled', async () => { - const result = await Promise.any([ - Promise.reject(new Error('fail1')), - Promise.resolve('success'), - Promise.reject(new Error('fail2')), - ]); - expect(result).toBe('success'); - }); - - it('should reject if all fail', async () => { - const promise = Promise.any([ - Promise.reject(new Error('fail1')), - Promise.reject(new Error('fail2')), - ]); - await expect(promise).rejects.toBeInstanceOf(AggregateError); - }); - }); - - describe('Sequential Execution', () => { - const sequential = async ( - items: T[], - fn: (item: T, index: number) => Promise - ): Promise => { - const results: R[] = []; - for (let i = 0; i < items.length; i++) { - results.push(await fn(items[i], i)); - } - return results; - }; - - it('should execute in order', async () => { - const order: number[] = []; - await sequential([1, 2, 3], async (n) => { - order.push(n); - return n; - }); - expect(order).toEqual([1, 2, 3]); - }); - - it('should return results in order', async () => { - const results = await sequential([1, 2, 3], async (n) => n * 2); - expect(results).toEqual([2, 4, 6]); - }); - - it('should stop on error', async () => { - const executed: number[] = []; - const promise = sequential([1, 2, 3], async (n) => { - executed.push(n); - if (n === 2) throw new Error('stop'); - return n; - }); - await expect(promise).rejects.toThrow('stop'); - expect(executed).toEqual([1, 2]); - }); - }); - - describe('Parallel with Limit', () => { - const parallelLimit = async ( - items: T[], - limit: number, - fn: (item: T) => Promise - ): Promise => { - const results: R[] = new Array(items.length); - let index = 0; - - const worker = async () => { - while (index < items.length) { - const currentIndex = index++; - results[currentIndex] = await fn(items[currentIndex]); - } - }; - - await Promise.all( - Array.from({ length: Math.min(limit, items.length) }, () => worker()) - ); - - return results; - }; - - it('should process all items', async () => { - const results = await parallelLimit([1, 2, 3, 4, 5], 2, async (n) => n * 2); - expect(results).toEqual([2, 4, 6, 8, 10]); - }); - - it('should limit concurrency', async () => { - let concurrent = 0; - let maxConcurrent = 0; - - await parallelLimit([1, 2, 3, 4, 5], 2, async (n) => { - concurrent++; - maxConcurrent = Math.max(maxConcurrent, concurrent); - await new Promise(resolve => setTimeout(resolve, 10)); - concurrent--; - return n; - }); - - expect(maxConcurrent).toBeLessThanOrEqual(2); - }); - - it('should handle empty array', async () => { - const results = await parallelLimit([], 2, async (n: number) => n); - expect(results).toEqual([]); - }); - }); - - describe('Async Map', () => { - const asyncMap = async ( - items: T[], - fn: (item: T, index: number) => Promise - ): Promise => { - return Promise.all(items.map(fn)); - }; - - it('should map async functions', async () => { - const results = await asyncMap([1, 2, 3], async (n) => n * 2); - expect(results).toEqual([2, 4, 6]); - }); - - it('should provide index', async () => { - const results = await asyncMap(['a', 'b', 'c'], async (_, i) => i); - expect(results).toEqual([0, 1, 2]); - }); - }); - - describe('Async Filter', () => { - const asyncFilter = async ( - items: T[], - predicate: (item: T) => Promise - ): Promise => { - const results = await Promise.all( - items.map(async (item) => ({ item, keep: await predicate(item) })) - ); - return results.filter(r => r.keep).map(r => r.item); - }; - - it('should filter with async predicate', async () => { - const results = await asyncFilter([1, 2, 3, 4, 5], async (n) => n % 2 === 0); - expect(results).toEqual([2, 4]); - }); - - it('should handle empty result', async () => { - const results = await asyncFilter([1, 3, 5], async (n) => n % 2 === 0); - expect(results).toEqual([]); - }); - }); - - describe('Async Find', () => { - const asyncFind = async ( - items: T[], - predicate: (item: T) => Promise - ): Promise => { - for (const item of items) { - if (await predicate(item)) { - return item; - } - } - return undefined; - }; - - it('should find first match', async () => { - const result = await asyncFind([1, 2, 3, 4, 5], async (n) => n > 3); - expect(result).toBe(4); - }); - - it('should return undefined if not found', async () => { - const result = await asyncFind([1, 2, 3], async (n) => n > 10); - expect(result).toBeUndefined(); - }); - - it('should short-circuit on first match', async () => { - const checked: number[] = []; - await asyncFind([1, 2, 3, 4, 5], async (n) => { - checked.push(n); - return n === 3; - }); - expect(checked).toEqual([1, 2, 3]); - }); - }); - - describe('Async Some/Every', () => { - const asyncSome = async ( - items: T[], - predicate: (item: T) => Promise - ): Promise => { - for (const item of items) { - if (await predicate(item)) return true; - } - return false; - }; - - const asyncEvery = async ( - items: T[], - predicate: (item: T) => Promise - ): Promise => { - for (const item of items) { - if (!(await predicate(item))) return false; - } - return true; - }; - - it('should return true if some match', async () => { - const result = await asyncSome([1, 2, 3], async (n) => n === 2); - expect(result).toBe(true); - }); - - it('should return false if none match', async () => { - const result = await asyncSome([1, 2, 3], async (n) => n === 5); - expect(result).toBe(false); - }); - - it('should return true if all match', async () => { - const result = await asyncEvery([2, 4, 6], async (n) => n % 2 === 0); - expect(result).toBe(true); - }); - - it('should return false if any fails', async () => { - const result = await asyncEvery([2, 3, 6], async (n) => n % 2 === 0); - expect(result).toBe(false); - }); - }); - - describe('Async Reduce', () => { - const asyncReduce = async ( - items: T[], - fn: (acc: R, item: T, index: number) => Promise, - initial: R - ): Promise => { - let acc = initial; - for (let i = 0; i < items.length; i++) { - acc = await fn(acc, items[i], i); - } - return acc; - }; - - it('should reduce with async function', async () => { - const result = await asyncReduce( - [1, 2, 3], - async (acc, n) => acc + n, - 0 - ); - expect(result).toBe(6); - }); - - it('should provide index', async () => { - const indices: number[] = []; - await asyncReduce( - ['a', 'b', 'c'], - async (acc, _, i) => { - indices.push(i); - return acc; - }, - 0 - ); - expect(indices).toEqual([0, 1, 2]); - }); - }); - - describe('Deferred Promise', () => { - interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (reason: unknown) => void; - } - - const createDeferred = (): Deferred => { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; - }; - - it('should resolve externally', async () => { - const deferred = createDeferred(); - deferred.resolve(42); - await expect(deferred.promise).resolves.toBe(42); - }); - - it('should reject externally', async () => { - const deferred = createDeferred(); - deferred.reject(new Error('fail')); - await expect(deferred.promise).rejects.toThrow('fail'); - }); - }); - - describe('Lazy Promise', () => { - const lazyPromise = (fn: () => Promise): (() => Promise) => { - let cached: Promise | null = null; - return () => { - if (!cached) { - cached = fn(); - } - return cached; - }; - }; - - it('should only execute once', async () => { - const fn = vi.fn().mockResolvedValue(42); - const lazy = lazyPromise(fn); - - await lazy(); - await lazy(); - await lazy(); - - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should return same result', async () => { - const lazy = lazyPromise(async () => Math.random()); - - const result1 = await lazy(); - const result2 = await lazy(); - - expect(result1).toBe(result2); - }); - }); - - describe('Mutex', () => { - class Mutex { - private locked = false; - private waitQueue: (() => void)[] = []; - - async acquire(): Promise { - if (!this.locked) { - this.locked = true; - return; - } - - return new Promise((resolve) => { - this.waitQueue.push(resolve); - }); - } - - release(): void { - if (this.waitQueue.length > 0) { - const next = this.waitQueue.shift()!; - next(); - } else { - this.locked = false; - } - } - - async withLock(fn: () => Promise): Promise { - await this.acquire(); - try { - return await fn(); - } finally { - this.release(); - } - } - - isLocked(): boolean { - return this.locked; - } - } - - it('should acquire and release', async () => { - const mutex = new Mutex(); - await mutex.acquire(); - expect(mutex.isLocked()).toBe(true); - mutex.release(); - expect(mutex.isLocked()).toBe(false); - }); - - it('should serialize access', async () => { - const mutex = new Mutex(); - const log: string[] = []; - - const task1 = mutex.withLock(async () => { - log.push('start1'); - await new Promise(r => setTimeout(r, 10)); - log.push('end1'); - }); - - const task2 = mutex.withLock(async () => { - log.push('start2'); - await new Promise(r => setTimeout(r, 10)); - log.push('end2'); - }); - - await Promise.all([task1, task2]); - - expect(log).toEqual(['start1', 'end1', 'start2', 'end2']); - }); - }); - - describe('Semaphore', () => { - class Semaphore { - private count: number; - private waitQueue: (() => void)[] = []; - - constructor(count: number) { - this.count = count; - } - - async acquire(): Promise { - if (this.count > 0) { - this.count--; - return; - } - - return new Promise((resolve) => { - this.waitQueue.push(resolve); - }); - } - - release(): void { - if (this.waitQueue.length > 0) { - const next = this.waitQueue.shift()!; - next(); - } else { - this.count++; - } - } - - getAvailable(): number { - return this.count; - } - } - - it('should allow multiple acquisitions', async () => { - const sem = new Semaphore(3); - await sem.acquire(); - await sem.acquire(); - expect(sem.getAvailable()).toBe(1); - }); - - it('should block when exhausted', async () => { - const sem = new Semaphore(1); - await sem.acquire(); - - let acquired = false; - const promise = sem.acquire().then(() => { - acquired = true; - }); - - await new Promise(r => setTimeout(r, 10)); - expect(acquired).toBe(false); - - sem.release(); - await promise; - expect(acquired).toBe(true); - }); - - it('should release properly', async () => { - const sem = new Semaphore(2); - await sem.acquire(); - await sem.acquire(); - expect(sem.getAvailable()).toBe(0); - - sem.release(); - sem.release(); - expect(sem.getAvailable()).toBe(2); - }); - }); - - describe('Async Queue', () => { - class AsyncQueue { - private items: T[] = []; - private waiters: ((item: T) => void)[] = []; - - push(item: T): void { - if (this.waiters.length > 0) { - const waiter = this.waiters.shift()!; - waiter(item); - } else { - this.items.push(item); - } - } - - async pop(): Promise { - if (this.items.length > 0) { - return this.items.shift()!; - } - - return new Promise((resolve) => { - this.waiters.push(resolve); - }); - } - - get size(): number { - return this.items.length; - } - - get waiting(): number { - return this.waiters.length; - } - } - - it('should pop available items', async () => { - const queue = new AsyncQueue(); - queue.push(1); - queue.push(2); - - expect(await queue.pop()).toBe(1); - expect(await queue.pop()).toBe(2); - }); - - it('should wait for items', async () => { - const queue = new AsyncQueue(); - - let received: number | null = null; - const promise = queue.pop().then((n) => { - received = n; - }); - - expect(queue.waiting).toBe(1); - queue.push(42); - - await promise; - expect(received).toBe(42); - }); - }); - - describe('Cancellation Token', () => { - class CancellationToken { - private cancelled = false; - private callbacks: (() => void)[] = []; - - cancel(): void { - this.cancelled = true; - this.callbacks.forEach(cb => cb()); - this.callbacks = []; - } - - isCancelled(): boolean { - return this.cancelled; - } - - onCancel(callback: () => void): void { - if (this.cancelled) { - callback(); - } else { - this.callbacks.push(callback); - } - } - - throwIfCancelled(): void { - if (this.cancelled) { - throw new Error('Operation cancelled'); - } - } - } - - it('should track cancellation state', () => { - const token = new CancellationToken(); - expect(token.isCancelled()).toBe(false); - token.cancel(); - expect(token.isCancelled()).toBe(true); - }); - - it('should call callbacks on cancel', () => { - const token = new CancellationToken(); - const callback = vi.fn(); - - token.onCancel(callback); - expect(callback).not.toHaveBeenCalled(); - - token.cancel(); - expect(callback).toHaveBeenCalled(); - }); - - it('should call callback immediately if already cancelled', () => { - const token = new CancellationToken(); - token.cancel(); - - const callback = vi.fn(); - token.onCancel(callback); - - expect(callback).toHaveBeenCalled(); - }); - - it('should throw if cancelled', () => { - const token = new CancellationToken(); - token.cancel(); - - expect(() => token.throwIfCancelled()).toThrow('Operation cancelled'); - }); - }); - - describe('Async Pool', () => { - const asyncPool = async ( - items: T[], - concurrency: number, - iteratee: (item: T) => Promise - ): Promise => { - const results: R[] = new Array(items.length); - let currentIndex = 0; - - const worker = async (): Promise => { - while (currentIndex < items.length) { - const index = currentIndex++; - results[index] = await iteratee(items[index]); - } - }; - - await Promise.all( - Array.from({ length: Math.min(concurrency, items.length) }, worker) - ); - - return results; - }; - - it('should process all items', async () => { - const items = [1, 2, 3, 4, 5]; - const results = await asyncPool(items, 2, async (n) => n * 2); - expect(results).toEqual([2, 4, 6, 8, 10]); - }); - - it('should handle single concurrency', async () => { - const items = [1, 2, 3]; - const order: number[] = []; - - await asyncPool(items, 1, async (n) => { - order.push(n); - return n; - }); - - expect(order).toEqual([1, 2, 3]); - }); - - it('should handle high concurrency', async () => { - const items = [1, 2, 3]; - const results = await asyncPool(items, 10, async (n) => n); - expect(results).toEqual([1, 2, 3]); - }); - }); - - describe('Retry Until', () => { - const retryUntil = async ( - fn: () => Promise, - predicate: (result: T) => boolean, - maxAttempts: number, - delay: number - ): Promise => { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await fn(); - if (predicate(result)) { - return result; - } - if (attempt < maxAttempts - 1) { - await new Promise(r => setTimeout(r, delay)); - } - } - throw new Error('Max attempts exceeded'); - }; - - it('should return when predicate passes', async () => { - let count = 0; - const result = await retryUntil( - async () => ++count, - (n) => n >= 3, - 5, - 0 - ); - expect(result).toBe(3); - }); - - it('should throw when max attempts exceeded', async () => { - const promise = retryUntil( - async () => 1, - () => false, - 3, - 0 - ); - await expect(promise).rejects.toThrow('Max attempts exceeded'); - }); - }); -}); diff --git a/test/browser-agent.test.ts b/test/browser-agent.test.ts deleted file mode 100644 index 6b3ce377..00000000 --- a/test/browser-agent.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -/** - * Agent-Browser Enhanced Test Suite - * - * Tests Claudeman web UI using agent-browser's AI-agent-friendly features: - * - Semantic locators (find by role, label, text) - * - Accessibility tree snapshots - * - Reference-based element selection (@e1, @e2) - * - * This demonstrates the "agentic" approach to browser testing where an AI - * could navigate the UI using natural language descriptions. - * - * Port: 3157 (see CLAUDE.md test port table) - */ - -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { execSync, spawnSync } from 'node:child_process'; -import { WebServer } from '../src/web/server.js'; - -const TEST_PORT = 3157; -const BASE_URL = `http://localhost:${TEST_PORT}`; -const BROWSER_TIMEOUT = 30000; - -// Metrics collection -interface TestMetrics { - testName: string; - duration: number; - framework: 'agent-browser'; - success: boolean; - error?: string; -} - -const metrics: TestMetrics[] = []; - -// ============================================================================ -// Agent-Browser CLI Helpers -// ============================================================================ - -function agentBrowser(command: string): string { - try { - return execSync(`npx agent-browser ${command}`, { - timeout: BROWSER_TIMEOUT, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - } catch (error: any) { - if (error.stderr) { - throw new Error(`agent-browser failed: ${error.stderr}`); - } - throw error; - } -} - -function agentBrowserJson(command: string): T { - const result = agentBrowser(`${command} --json`); - const parsed = JSON.parse(result); - if (!parsed.success) { - throw new Error(`agent-browser command failed: ${parsed.error || 'unknown error'}`); - } - return parsed.data; -} - -// Get accessibility tree snapshot for AI-based navigation -function getSnapshot(): string { - try { - return agentBrowser('snapshot'); - } catch { - return ''; - } -} - -// Find element by semantic criteria (AI-friendly) -function findByRole(role: string, name?: string): boolean { - try { - const cmd = name - ? `find role ${role} --name "${name}"` - : `find role ${role}`; - const result = agentBrowser(cmd); - return result.includes('Found') || !result.includes('not found'); - } catch { - return false; - } -} - -function findByText(text: string): boolean { - try { - const result = agentBrowser(`find text "${text}"`); - return result.includes('Found') || !result.includes('not found'); - } catch { - return false; - } -} - -function findByLabel(label: string): boolean { - try { - const result = agentBrowser(`find label "${label}"`); - return result.includes('Found') || !result.includes('not found'); - } catch { - return false; - } -} - -// Click using semantic finder -function clickByText(text: string): boolean { - try { - agentBrowser(`find text "${text}" click`); - return true; - } catch { - return false; - } -} - -function clickByRole(role: string, name?: string): boolean { - try { - const cmd = name - ? `find role ${role} click --name "${name}"` - : `find role ${role} click`; - agentBrowser(cmd); - return true; - } catch { - return false; - } -} - -// Standard helpers -function getText(selector: string): string { - try { - return agentBrowserJson<{ text: string }>(`get text "${selector}"`).text || ''; - } catch { - return ''; - } -} - -function isVisible(selector: string): boolean { - try { - return agentBrowserJson<{ visible: boolean }>(`is visible "${selector}"`).visible; - } catch { - return false; - } -} - -function getCount(selector: string): number { - try { - return agentBrowserJson<{ count: number }>(`get count "${selector}"`).count; - } catch { - return 0; - } -} - -async function waitForElement(selector: string, timeout = 10000): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - if (getCount(selector) > 0) return true; - await new Promise(r => setTimeout(r, 500)); - } - return false; -} - -function closeBrowser() { - try { - agentBrowser('close'); - } catch { /* ignore */ } -} - -describe('Agent-Browser Enhanced Tests', () => { - let server: WebServer; - let createdSessions: string[] = []; - let browserAvailable = false; - let testStartTime: number; - - beforeAll(async () => { - closeBrowser(); - - // Start web server - server = new WebServer(TEST_PORT, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - - // Test if agent-browser is available - try { - agentBrowser(`open ${BASE_URL}`); - await new Promise(r => setTimeout(r, 2000)); - const title = agentBrowserJson<{ title: string }>('get title'); - browserAvailable = title.title === 'Claudeman'; - } catch (e) { - console.warn('Agent-browser not available, skipping tests:', (e as Error).message); - browserAvailable = false; - } - }, 60000); - - afterAll(async () => { - closeBrowser(); - - // Cleanup sessions - for (const sessionId of createdSessions) { - try { - await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - - await server.stop(); - - // Output metrics - console.log('\n📊 Agent-Browser Test Metrics:'); - console.table(metrics.map(m => ({ - test: m.testName.substring(0, 40), - duration: `${m.duration}ms`, - status: m.success ? '✅' : '❌', - }))); - }, 60000); - - beforeEach(() => { - testStartTime = Date.now(); - }); - - afterEach(() => { - const testInfo = expect.getState(); - metrics.push({ - testName: testInfo.currentTestName || 'unknown', - duration: Date.now() - testStartTime, - framework: 'agent-browser', - success: !testInfo.assertionCalls?.some((c: any) => !c.pass), - }); - }); - - // ============================================================================ - // Core Page Tests (CSS Selectors) - // ============================================================================ - - it('should load the main page with correct title', async () => { - if (!browserAvailable) { - console.log('Skipping: browser not available'); - return; - } - - const title = agentBrowserJson<{ title: string }>('get title'); - expect(title.title).toBe('Claudeman'); - }); - - it('should display the logo and branding', async () => { - if (!browserAvailable) return; - - const logoText = getText('.header-brand .logo'); - expect(logoText).toBe('Claudeman'); - }); - - it('should show version in toolbar', async () => { - if (!browserAvailable) return; - - const version = getText('#versionDisplay'); - expect(version).toMatch(/v\d+\.\d+/); - }); - - // ============================================================================ - // Semantic/AI-Friendly Tests - // ============================================================================ - - it('should find elements using semantic role locators', async () => { - if (!browserAvailable) return; - - // Find buttons by role - const hasButtons = findByRole('button'); - expect(hasButtons).toBe(true); - - // Find specific buttons (may vary based on page structure) - // agent-browser uses ARIA roles for semantic finding - }); - - it('should capture accessibility tree snapshot', async () => { - if (!browserAvailable) return; - - const snapshot = getSnapshot(); - - // Snapshot should contain accessibility information - expect(snapshot.length).toBeGreaterThan(0); - - // Should include key page elements - // The snapshot format varies by agent-browser version - console.log('Accessibility snapshot captured, length:', snapshot.length); - }); - - it('should find elements by text content', async () => { - if (!browserAvailable) return; - - // Find elements containing specific text - const foundClaudeman = findByText('Claudeman'); - expect(foundClaudeman).toBe(true); - }); - - // ============================================================================ - // Modal Tests (CSS + Semantic) - // ============================================================================ - - it('should open and close help modal', async () => { - if (!browserAvailable) return; - - // Open using CSS selector - agentBrowser('click ".help-btn"'); - await new Promise(r => setTimeout(r, 500)); - - expect(isVisible('#helpModal .modal-content')).toBe(true); - - // Find heading by text - const headingText = getText('#helpModal h3'); - expect(headingText).toContain('Keyboard Shortcuts'); - - // Close modal - agentBrowser('click "#helpModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - - expect(isVisible('#helpModal .modal-content')).toBe(false); - }); - - it('should open settings modal with correct tabs', async () => { - if (!browserAvailable) return; - - agentBrowser('click ".btn-settings"'); - await new Promise(r => setTimeout(r, 500)); - - expect(isVisible('#appSettingsModal .modal-content')).toBe(true); - - const tabCount = getCount('#appSettingsModal .modal-tab-btn'); - expect(tabCount).toBe(4); - - agentBrowser('click "#appSettingsModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - }); - - // ============================================================================ - // Session Management Tests - // ============================================================================ - - it('should create a new session via button click', async () => { - if (!browserAvailable) return; - - // Click create session - agentBrowser('click ".btn-claude"'); - - // Wait for session tab - const tabFound = await waitForElement('.session-tab', 15000); - expect(tabFound).toBe(true); - - await new Promise(r => setTimeout(r, 1000)); - expect(isVisible('.session-tab.active')).toBe(true); - - // Terminal should be visible - const xtermVisible = await waitForElement('.xterm', 10000); - expect(xtermVisible).toBe(true); - - // Track for cleanup - const response = await fetch(`${BASE_URL}/api/sessions`); - const data = await response.json(); - if (data.sessions?.length > 0) { - const lastSession = data.sessions[data.sessions.length - 1]; - if (lastSession && !createdSessions.includes(lastSession.id)) { - createdSessions.push(lastSession.id); - } - } - }, 30000); - - // ============================================================================ - // AI-Driven Navigation Demo - // ============================================================================ - - it('should demonstrate AI-friendly navigation workflow', async () => { - if (!browserAvailable) return; - - // This test demonstrates how an AI agent could navigate the UI - // using semantic locators and natural language-like commands - - // Step 1: Check page state via snapshot - const snapshot = getSnapshot(); - expect(snapshot.length).toBeGreaterThan(0); - - // Step 2: Find and interact with settings - // An AI would parse the snapshot and decide what to click - const settingsVisible = isVisible('.btn-settings'); - expect(settingsVisible).toBe(true); - - // Step 3: Open settings - agentBrowser('click ".btn-settings"'); - await new Promise(r => setTimeout(r, 500)); - - // Step 4: Take new snapshot to understand modal state - const modalSnapshot = getSnapshot(); - - // Step 5: Close settings - agentBrowser('click "#appSettingsModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - - // This workflow shows how an AI could: - // 1. Observe the page state (snapshot) - // 2. Decide on actions based on semantic understanding - // 3. Execute actions - // 4. Verify results with new snapshot - }); - - // ============================================================================ - // Form Interaction Tests (Semantic) - // ============================================================================ - - it('should interact with quick start dropdown', async () => { - if (!browserAvailable) return; - - // Find the quick start dropdown - const dropdownVisible = isVisible('#quickStartCase'); - expect(dropdownVisible).toBe(true); - - // Get current value - // Note: agent-browser's get value command for select elements - try { - const value = agentBrowserJson<{ value: string }>('get value "#quickStartCase"'); - // Value should be the current selected case - expect(typeof value.value).toBe('string'); - } catch { - // May not be a standard select element - } - }); - - // ============================================================================ - // Multiple Element Tests - // ============================================================================ - - it('should count toolbar elements correctly', async () => { - if (!browserAvailable) return; - - // Count various toolbar elements - const fontControls = getCount('.header-font-controls .font-btn'); - expect(fontControls).toBeGreaterThanOrEqual(2); - - // Tab count badge should exist - expect(isVisible('#tabCount')).toBe(true); - }); - - // ============================================================================ - // Screenshot Test - // ============================================================================ - - it('should capture screenshot for AI analysis', async () => { - if (!browserAvailable) return; - - try { - // Capture screenshot (useful for visual AI analysis) - agentBrowser('screenshot /tmp/claudeman-test.png'); - - // Verify file was created (would exist after successful screenshot) - // In a real AI scenario, this image would be analyzed - } catch { - // Screenshot may fail in headless mode without proper setup - } - }); - - // ============================================================================ - // Stress Tests - // ============================================================================ - - it('should handle rapid interactions', async () => { - if (!browserAvailable) return; - - // Rapid modal open/close - for (let i = 0; i < 3; i++) { - agentBrowser('click ".help-btn"'); - await new Promise(r => setTimeout(r, 200)); - try { - agentBrowser('click "#helpModal .modal-close"'); - } catch { /* may already be closed */ } - await new Promise(r => setTimeout(r, 200)); - } - - // Page should still be functional - expect(isVisible('.header')).toBe(true); - }); - - // ============================================================================ - // Tab Navigation Tests - // ============================================================================ - - it('should handle tab count display', async () => { - if (!browserAvailable) return; - - const tabCountText = getText('#tabCount'); - // Should show number of tabs - expect(tabCountText).toMatch(/\d+/); - }); -}); - -// Export metrics for comparison -export { metrics as agentBrowserMetrics }; diff --git a/test/browser-comparison.test.ts b/test/browser-comparison.test.ts deleted file mode 100644 index b2113c35..00000000 --- a/test/browser-comparison.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * Browser Framework Comparison Benchmark - * - * Runs identical test scenarios across Playwright, Puppeteer, and Agent-Browser - * to compare performance, reliability, and ease of use for agentic testing. - * - * This benchmark measures: - * - Execution time per operation - * - Reliability (success rate) - * - Memory usage (where available) - * - Ease of implementation (qualitative) - * - * Ports: 3158 (Playwright), 3159 (Puppeteer), 3160 (Agent-Browser) - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { chromium, Browser as PlaywrightBrowser, Page as PlaywrightPage } from 'playwright'; -import puppeteer, { Browser as PuppeteerBrowser, Page as PuppeteerPage } from 'puppeteer'; -import { execSync } from 'node:child_process'; -import { WebServer } from '../src/web/server.js'; - -// Port allocation -const PORTS = { - playwright: 3158, - puppeteer: 3159, - agentBrowser: 3160, -}; - -// ============================================================================ -// Types -// ============================================================================ - -interface BenchmarkResult { - scenario: string; - framework: 'playwright' | 'puppeteer' | 'agent-browser'; - duration: number; - success: boolean; - error?: string; -} - -interface AggregateStats { - framework: string; - totalTests: number; - passed: number; - failed: number; - avgDuration: number; - minDuration: number; - maxDuration: number; - successRate: string; -} - -const results: BenchmarkResult[] = []; - -// ============================================================================ -// Agent-Browser Helpers -// ============================================================================ - -function agentBrowser(command: string): string { - try { - return execSync(`npx agent-browser ${command}`, { - timeout: 30000, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - } catch (error: any) { - throw new Error(`agent-browser failed: ${error.stderr || error.message}`); - } -} - -function agentBrowserJson(command: string): T { - const result = agentBrowser(`${command} --json`); - const parsed = JSON.parse(result); - if (!parsed.success) { - throw new Error(`agent-browser: ${parsed.error || 'unknown error'}`); - } - return parsed.data; -} - -// ============================================================================ -// Benchmark Scenarios -// ============================================================================ - -/** - * Each scenario is a function that takes framework-specific parameters - * and returns a promise that resolves when the scenario is complete. - */ - -async function runBenchmark( - scenario: string, - framework: 'playwright' | 'puppeteer' | 'agent-browser', - fn: () => Promise -): Promise { - const start = Date.now(); - let success = false; - let error: string | undefined; - - try { - await fn(); - success = true; - } catch (e: any) { - error = e.message; - } - - results.push({ - scenario, - framework, - duration: Date.now() - start, - success, - error, - }); -} - -// ============================================================================ -// Main Comparison Suite -// ============================================================================ - -describe('Browser Framework Comparison Benchmark', () => { - // Framework instances - let playwrightBrowser: PlaywrightBrowser; - let puppeteerBrowser: PuppeteerBrowser; - let agentBrowserAvailable = false; - - // Web servers - let servers: WebServer[] = []; - - // Cleanup tracking - let createdSessions: { port: number; id: string }[] = []; - - beforeAll(async () => { - console.log('\n🚀 Starting Browser Framework Comparison Benchmark\n'); - console.log('This benchmark compares:'); - console.log(' - Playwright (direct API)'); - console.log(' - Puppeteer (Chrome DevTools Protocol)'); - console.log(' - Agent-Browser (CLI-based, AI-friendly)\n'); - - // Start web servers for each framework - for (const [name, port] of Object.entries(PORTS)) { - const server = new WebServer(port, false, true); - await server.start(); - servers.push(server); - console.log(`✅ Started server for ${name} on port ${port}`); - } - - await new Promise(r => setTimeout(r, 1000)); - - // Initialize Playwright - try { - playwrightBrowser = await chromium.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], - }); - console.log('✅ Playwright browser launched'); - } catch (e: any) { - console.warn('⚠️ Playwright launch failed:', e.message); - } - - // Initialize Puppeteer - try { - puppeteerBrowser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - }); - console.log('✅ Puppeteer browser launched'); - } catch (e: any) { - console.warn('⚠️ Puppeteer launch failed:', e.message); - } - - // Test Agent-Browser availability - try { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 2000)); - const title = agentBrowserJson<{ title: string }>('get title'); - agentBrowserAvailable = title.title === 'Claudeman'; - if (agentBrowserAvailable) { - console.log('✅ Agent-Browser available'); - } - } catch (e: any) { - console.warn('⚠️ Agent-Browser not available:', e.message); - } - - console.log('\n' + '='.repeat(60) + '\n'); - }, 120000); - - afterAll(async () => { - // Cleanup sessions - for (const { port, id } of createdSessions) { - try { - await fetch(`http://localhost:${port}/api/sessions/${id}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - - // Close browsers - if (playwrightBrowser) await playwrightBrowser.close(); - if (puppeteerBrowser) await puppeteerBrowser.close(); - try { agentBrowser('close'); } catch { /* ignore */ } - - // Stop servers - for (const server of servers) { - await server.stop(); - } - - // Print results summary - printResultsSummary(); - }, 60000); - - // ============================================================================ - // Scenario 1: Page Load - // ============================================================================ - - describe('Scenario: Page Load', () => { - it('Playwright - page load', async () => { - if (!playwrightBrowser) return; - - await runBenchmark('page-load', 'playwright', async () => { - const context = await playwrightBrowser.newContext(); - const page = await context.newPage(); - await page.goto(`http://localhost:${PORTS.playwright}`); - await expect(page).toHaveTitle('Claudeman'); - await context.close(); - }); - }); - - it('Puppeteer - page load', async () => { - if (!puppeteerBrowser) return; - - await runBenchmark('page-load', 'puppeteer', async () => { - const page = await puppeteerBrowser.newPage(); - await page.goto(`http://localhost:${PORTS.puppeteer}`); - const title = await page.title(); - expect(title).toBe('Claudeman'); - await page.close(); - }); - }); - - it('Agent-Browser - page load', async () => { - if (!agentBrowserAvailable) return; - - await runBenchmark('page-load', 'agent-browser', async () => { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 1000)); - const title = agentBrowserJson<{ title: string }>('get title'); - expect(title.title).toBe('Claudeman'); - }); - }); - }); - - // ============================================================================ - // Scenario 2: Element Selection - // ============================================================================ - - describe('Scenario: Element Selection', () => { - it('Playwright - element selection', async () => { - if (!playwrightBrowser) return; - - await runBenchmark('element-selection', 'playwright', async () => { - const context = await playwrightBrowser.newContext(); - const page = await context.newPage(); - await page.goto(`http://localhost:${PORTS.playwright}`); - - // Multiple element selections - const logo = await page.locator('.header-brand .logo').textContent(); - const version = await page.locator('#versionDisplay').textContent(); - const btnCount = await page.locator('.btn-claude').count(); - - expect(logo).toBe('Claudeman'); - expect(version).toMatch(/v\d+/); - expect(btnCount).toBeGreaterThan(0); - - await context.close(); - }); - }); - - it('Puppeteer - element selection', async () => { - if (!puppeteerBrowser) return; - - await runBenchmark('element-selection', 'puppeteer', async () => { - const page = await puppeteerBrowser.newPage(); - await page.goto(`http://localhost:${PORTS.puppeteer}`); - - const logo = await page.$eval('.header-brand .logo', el => el.textContent); - const version = await page.$eval('#versionDisplay', el => el.textContent); - const btns = await page.$$('.btn-claude'); - - expect(logo).toBe('Claudeman'); - expect(version).toMatch(/v\d+/); - expect(btns.length).toBeGreaterThan(0); - - await page.close(); - }); - }); - - it('Agent-Browser - element selection', async () => { - if (!agentBrowserAvailable) return; - - await runBenchmark('element-selection', 'agent-browser', async () => { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 1000)); - - const logo = agentBrowserJson<{ text: string }>('get text ".header-brand .logo"'); - const version = agentBrowserJson<{ text: string }>('get text "#versionDisplay"'); - const btnCount = agentBrowserJson<{ count: number }>('get count ".btn-claude"'); - - expect(logo.text).toBe('Claudeman'); - expect(version.text).toMatch(/v\d+/); - expect(btnCount.count).toBeGreaterThan(0); - }); - }); - }); - - // ============================================================================ - // Scenario 3: Modal Interaction - // ============================================================================ - - describe('Scenario: Modal Open/Close', () => { - it('Playwright - modal interaction', async () => { - if (!playwrightBrowser) return; - - await runBenchmark('modal-interaction', 'playwright', async () => { - const context = await playwrightBrowser.newContext(); - const page = await context.newPage(); - await page.goto(`http://localhost:${PORTS.playwright}`); - - // Open modal - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { state: 'visible' }); - - // Verify content - const heading = await page.locator('#helpModal h3').textContent(); - expect(heading).toContain('Keyboard Shortcuts'); - - // Close modal - await page.click('#helpModal .modal-close'); - await page.waitForSelector('#helpModal .modal-content', { state: 'hidden' }); - - await context.close(); - }); - }); - - it('Puppeteer - modal interaction', async () => { - if (!puppeteerBrowser) return; - - await runBenchmark('modal-interaction', 'puppeteer', async () => { - const page = await puppeteerBrowser.newPage(); - await page.goto(`http://localhost:${PORTS.puppeteer}`); - - // Open modal - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { visible: true }); - - // Verify content - const heading = await page.$eval('#helpModal h3', el => el.textContent); - expect(heading).toContain('Keyboard Shortcuts'); - - // Close modal - await page.click('#helpModal .modal-close'); - await page.waitForFunction(() => { - const modal = document.querySelector('#helpModal .modal-content'); - return !modal || window.getComputedStyle(modal).display === 'none'; - }); - - await page.close(); - }); - }); - - it('Agent-Browser - modal interaction', async () => { - if (!agentBrowserAvailable) return; - - await runBenchmark('modal-interaction', 'agent-browser', async () => { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 1000)); - - // Open modal - agentBrowser('click ".help-btn"'); - await new Promise(r => setTimeout(r, 500)); - - // Verify content - const heading = agentBrowserJson<{ text: string }>('get text "#helpModal h3"'); - expect(heading.text).toContain('Keyboard Shortcuts'); - - // Close modal - agentBrowser('click "#helpModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - }); - }); - }); - - // ============================================================================ - // Scenario 4: Session Creation (Complex) - // ============================================================================ - - describe('Scenario: Session Creation', () => { - it('Playwright - session creation', async () => { - if (!playwrightBrowser) return; - - await runBenchmark('session-creation', 'playwright', async () => { - const context = await playwrightBrowser.newContext(); - const page = await context.newPage(); - await page.goto(`http://localhost:${PORTS.playwright}`); - - // Create session - await page.click('.btn-claude'); - - // Wait for session tab - await page.waitForSelector('.session-tab', { timeout: 15000 }); - - // Verify terminal appears - await page.waitForSelector('.xterm', { timeout: 10000 }); - - // Track for cleanup - const response = await fetch(`http://localhost:${PORTS.playwright}/api/sessions`); - const data = await response.json(); - const lastSession = data.sessions?.[data.sessions.length - 1]; - if (lastSession) { - createdSessions.push({ port: PORTS.playwright, id: lastSession.id }); - } - - await context.close(); - }); - }, 30000); - - it('Puppeteer - session creation', async () => { - if (!puppeteerBrowser) return; - - await runBenchmark('session-creation', 'puppeteer', async () => { - const page = await puppeteerBrowser.newPage(); - await page.goto(`http://localhost:${PORTS.puppeteer}`); - - // Create session - await page.click('.btn-claude'); - - // Wait for session tab - await page.waitForSelector('.session-tab', { timeout: 15000 }); - - // Verify terminal appears - await page.waitForSelector('.xterm', { timeout: 10000 }); - - // Track for cleanup - const response = await fetch(`http://localhost:${PORTS.puppeteer}/api/sessions`); - const data = await response.json(); - const lastSession = data.sessions?.[data.sessions.length - 1]; - if (lastSession) { - createdSessions.push({ port: PORTS.puppeteer, id: lastSession.id }); - } - - await page.close(); - }); - }, 30000); - - it('Agent-Browser - session creation', async () => { - if (!agentBrowserAvailable) return; - - await runBenchmark('session-creation', 'agent-browser', async () => { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 1000)); - - // Create session - agentBrowser('click ".btn-claude"'); - - // Wait for session tab - const start = Date.now(); - while (Date.now() - start < 15000) { - try { - const count = agentBrowserJson<{ count: number }>('get count ".session-tab"'); - if (count.count > 0) break; - } catch { /* retry */ } - await new Promise(r => setTimeout(r, 500)); - } - - // Verify terminal appears - await new Promise(r => setTimeout(r, 2000)); - const xtermCount = agentBrowserJson<{ count: number }>('get count ".xterm"'); - expect(xtermCount.count).toBeGreaterThan(0); - - // Track for cleanup - const response = await fetch(`http://localhost:${PORTS.agentBrowser}/api/sessions`); - const data = await response.json(); - const lastSession = data.sessions?.[data.sessions.length - 1]; - if (lastSession) { - createdSessions.push({ port: PORTS.agentBrowser, id: lastSession.id }); - } - }); - }, 30000); - }); - - // ============================================================================ - // Scenario 5: Rapid Operations - // ============================================================================ - - describe('Scenario: Rapid Operations (5 modal cycles)', () => { - it('Playwright - rapid operations', async () => { - if (!playwrightBrowser) return; - - await runBenchmark('rapid-operations', 'playwright', async () => { - const context = await playwrightBrowser.newContext(); - const page = await context.newPage(); - await page.goto(`http://localhost:${PORTS.playwright}`); - - for (let i = 0; i < 5; i++) { - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { state: 'visible' }); - await page.click('#helpModal .modal-close'); - await page.waitForSelector('#helpModal .modal-content', { state: 'hidden' }); - } - - await context.close(); - }); - }); - - it('Puppeteer - rapid operations', async () => { - if (!puppeteerBrowser) return; - - await runBenchmark('rapid-operations', 'puppeteer', async () => { - const page = await puppeteerBrowser.newPage(); - await page.goto(`http://localhost:${PORTS.puppeteer}`); - - for (let i = 0; i < 5; i++) { - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { visible: true }); - await page.click('#helpModal .modal-close'); - await new Promise(r => setTimeout(r, 200)); - } - - await page.close(); - }); - }); - - it('Agent-Browser - rapid operations', async () => { - if (!agentBrowserAvailable) return; - - await runBenchmark('rapid-operations', 'agent-browser', async () => { - agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); - await new Promise(r => setTimeout(r, 1000)); - - for (let i = 0; i < 5; i++) { - agentBrowser('click ".help-btn"'); - await new Promise(r => setTimeout(r, 300)); - agentBrowser('click "#helpModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - } - }); - }); - }); -}); - -// ============================================================================ -// Results Summary -// ============================================================================ - -function printResultsSummary(): void { - console.log('\n' + '='.repeat(70)); - console.log('📊 BROWSER FRAMEWORK COMPARISON RESULTS'); - console.log('='.repeat(70) + '\n'); - - // Group by framework - const frameworks = ['playwright', 'puppeteer', 'agent-browser'] as const; - const stats: AggregateStats[] = []; - - for (const framework of frameworks) { - const frameworkResults = results.filter(r => r.framework === framework); - if (frameworkResults.length === 0) continue; - - const passed = frameworkResults.filter(r => r.success).length; - const durations = frameworkResults.map(r => r.duration); - - stats.push({ - framework, - totalTests: frameworkResults.length, - passed, - failed: frameworkResults.length - passed, - avgDuration: Math.round(durations.reduce((a, b) => a + b, 0) / durations.length), - minDuration: Math.min(...durations), - maxDuration: Math.max(...durations), - successRate: ((passed / frameworkResults.length) * 100).toFixed(1) + '%', - }); - } - - // Print aggregate stats - console.log('📈 AGGREGATE STATISTICS\n'); - console.table(stats.map(s => ({ - Framework: s.framework.toUpperCase(), - Tests: s.totalTests, - Passed: s.passed, - Failed: s.failed, - 'Success Rate': s.successRate, - 'Avg (ms)': s.avgDuration, - 'Min (ms)': s.minDuration, - 'Max (ms)': s.maxDuration, - }))); - - // Print detailed results by scenario - console.log('\n📋 DETAILED RESULTS BY SCENARIO\n'); - - const scenarios = [...new Set(results.map(r => r.scenario))]; - for (const scenario of scenarios) { - console.log(`\n🔹 ${scenario.toUpperCase()}`); - const scenarioResults = results.filter(r => r.scenario === scenario); - - console.table(scenarioResults.map(r => ({ - Framework: r.framework, - Duration: `${r.duration}ms`, - Status: r.success ? '✅ Pass' : '❌ Fail', - Error: r.error ? r.error.substring(0, 40) + '...' : '-', - }))); - } - - // Performance comparison - console.log('\n🏆 PERFORMANCE RANKING (by avg duration)\n'); - const ranked = [...stats].sort((a, b) => a.avgDuration - b.avgDuration); - ranked.forEach((s, i) => { - const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'; - console.log(`${medal} ${s.framework.toUpperCase()}: ${s.avgDuration}ms avg (${s.successRate} success rate)`); - }); - - // Recommendations - console.log('\n💡 RECOMMENDATIONS\n'); - console.log('• Playwright: Best for comprehensive testing with excellent debugging'); - console.log('• Puppeteer: Best for Chrome-specific features and CDP access'); - console.log('• Agent-Browser: Best for AI agents with semantic navigation needs'); - - console.log('\n' + '='.repeat(70) + '\n'); -} - -// Export results for external analysis -export { results as comparisonResults }; diff --git a/test/browser-e2e.test.ts b/test/browser-e2e.test.ts deleted file mode 100644 index a4bab805..00000000 --- a/test/browser-e2e.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -/** - * Browser E2E Tests for Claudeman Web UI - * - * Uses agent-browser (which wraps Playwright) for browser automation. - * These tests verify the web interface works correctly from a user perspective. - * - * Port allocation: 3150-3153 (see CLAUDE.md test port table) - * - * NOTE: Browser tests require agent-browser daemon to be responsive. - * API-only tests (SSE, Hook, Ralph) run without browser dependency. - * - * Run just API tests: npx vitest run test/browser-e2e.test.ts -t "API" - * Run just browser tests: npx vitest run test/browser-e2e.test.ts -t "Browser" - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execSync } from 'node:child_process'; -import { WebServer } from '../src/web/server.js'; - -// ============================================================================ -// Browser E2E Tests (require agent-browser daemon) -// ============================================================================ - -const TEST_PORT_BROWSER = 3150; -const browserBaseUrl = `http://localhost:${TEST_PORT_BROWSER}`; -const BROWSER_TIMEOUT = 30000; - -// Helper to run agent-browser commands -function browser(command: string): string { - try { - return execSync(`npx agent-browser ${command}`, { - timeout: BROWSER_TIMEOUT, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - } catch (error: any) { - if (error.stderr) { - throw new Error(`agent-browser failed: ${error.stderr}`); - } - throw error; - } -} - -function browserJson(command: string): T { - const result = browser(`${command} --json`); - const parsed = JSON.parse(result); - if (!parsed.success) { - throw new Error(`agent-browser command failed: ${parsed.error || 'unknown error'}`); - } - return parsed.data; -} - -async function waitForElement(selector: string, timeout = 10000): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - try { - const count = browserJson<{ count: number }>(`get count "${selector}"`); - if (count.count > 0) return true; - } catch { /* retry */ } - await new Promise(r => setTimeout(r, 500)); - } - return false; -} - -function getText(selector: string): string { - try { - return browserJson<{ text: string }>(`get text "${selector}"`).text || ''; - } catch { - return ''; - } -} - -function isVisible(selector: string): boolean { - try { - return browserJson<{ visible: boolean }>(`is visible "${selector}"`).visible; - } catch { - return false; - } -} - -function closeBrowser() { - try { - browser('close'); - } catch { /* ignore */ } -} - -describe('Browser E2E Tests', () => { - let server: WebServer; - let createdSessions: string[] = []; - let browserAvailable = false; - - beforeAll(async () => { - closeBrowser(); - - server = new WebServer(TEST_PORT_BROWSER, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - - // Test if browser is available - try { - browser(`open ${browserBaseUrl}`); - await new Promise(r => setTimeout(r, 2000)); - const title = browserJson<{ title: string }>('get title'); - browserAvailable = title.title === 'Claudeman'; - } catch (e) { - console.warn('Browser not available, skipping browser tests:', (e as Error).message); - browserAvailable = false; - } - }, 60000); - - afterAll(async () => { - closeBrowser(); - for (const sessionId of createdSessions) { - try { - await fetch(`${browserBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - await server.stop(); - }, 60000); - - it('should load the Claudeman web interface', async () => { - if (!browserAvailable) { - console.log('Skipping: browser not available'); - return; - } - - const title = browserJson<{ title: string }>('get title'); - expect(title.title).toBe('Claudeman'); - - const logoText = getText('.header-brand .logo'); - expect(logoText).toBe('Claudeman'); - - expect(isVisible('.btn-claude')).toBe(true); - }, 60000); - - it('should open and close help modal', async () => { - if (!browserAvailable) return; - - browser('click ".help-btn"'); - await new Promise(r => setTimeout(r, 500)); - - expect(isVisible('#helpModal .modal-content')).toBe(true); - expect(getText('#helpModal h3')).toContain('Keyboard Shortcuts'); - - browser('click "#helpModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - - expect(isVisible('#helpModal .modal-content')).toBe(false); - }, 60000); - - it('should open settings modal with correct tabs', async () => { - if (!browserAvailable) return; - - browser('click ".btn-settings"'); - await new Promise(r => setTimeout(r, 500)); - - expect(isVisible('#appSettingsModal .modal-content')).toBe(true); - - const tabCount = browserJson<{ count: number }>('get count "#appSettingsModal .modal-tab-btn"'); - expect(tabCount.count).toBe(4); - - browser('click "#appSettingsModal .modal-close"'); - await new Promise(r => setTimeout(r, 300)); - }, 60000); - - it('should have all toolbar elements', async () => { - if (!browserAvailable) return; - - expect(isVisible('.header-font-controls')).toBe(true); - expect(isVisible('#tabCount')).toBe(true); - expect(isVisible('#quickStartCase')).toBe(true); - - const versionText = getText('#versionDisplay'); - expect(versionText).toMatch(/v\d+\.\d+/); - }, 60000); - - it('should create session and show terminal', async () => { - if (!browserAvailable) return; - - browser('click ".btn-claude"'); - - const tabFound = await waitForElement('.session-tab', 15000); - expect(tabFound).toBe(true); - - await new Promise(r => setTimeout(r, 1000)); - expect(isVisible('.session-tab.active')).toBe(true); - - const xtermVisible = await waitForElement('.xterm', 10000); - expect(xtermVisible).toBe(true); - - // Track for cleanup - const response = await fetch(`${browserBaseUrl}/api/sessions`); - const data = await response.json(); - if (data.sessions?.length > 0) { - const lastSession = data.sessions[data.sessions.length - 1]; - if (lastSession && !createdSessions.includes(lastSession.id)) { - createdSessions.push(lastSession.id); - } - } - }, 90000); -}); - -// ============================================================================ -// API Tests (no browser dependency) -// ============================================================================ - -describe('SSE Events API', () => { - let server: WebServer; - const TEST_PORT_SSE = 3151; - const sseBaseUrl = `http://localhost:${TEST_PORT_SSE}`; - let createdSessions: string[] = []; - - beforeAll(async () => { - server = new WebServer(TEST_PORT_SSE, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - }, 30000); - - afterAll(async () => { - for (const sessionId of createdSessions) { - try { - await fetch(`${sseBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - await server.stop(); - }, 60000); - - it('should connect to SSE endpoint', async () => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2000); - - try { - const response = await fetch(`${sseBaseUrl}/api/events`, { - signal: controller.signal, - headers: { 'Accept': 'text/event-stream' }, - }); - - expect(response.headers.get('content-type')).toBe('text/event-stream'); - } catch (err: any) { - if (err.name !== 'AbortError') throw err; - } finally { - clearTimeout(timeout); - } - }); - - it('should receive init event on connection', async () => { - const controller = new AbortController(); - let receivedData = ''; - const timeout = setTimeout(() => controller.abort(), 2000); - - try { - const response = await fetch(`${sseBaseUrl}/api/events`, { - signal: controller.signal, - }); - - const reader = response.body?.getReader(); - if (reader) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - receivedData += new TextDecoder().decode(value); - } - } - } catch (err: any) { - if (err.name !== 'AbortError') throw err; - } finally { - clearTimeout(timeout); - } - - // Should contain init event - expect(receivedData).toContain('event: init'); - expect(receivedData).toContain('"sessions"'); - }); -}); - -describe('Hook Events API', () => { - let server: WebServer; - const TEST_PORT_HOOK = 3152; - const hookBaseUrl = `http://localhost:${TEST_PORT_HOOK}`; - let createdSessions: string[] = []; - - beforeAll(async () => { - server = new WebServer(TEST_PORT_HOOK, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - }, 30000); - - afterAll(async () => { - for (const sessionId of createdSessions) { - try { - await fetch(`${hookBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - await server.stop(); - }, 60000); - - it('should accept hook events via API', async () => { - // Create a session - const createRes = await fetch(`${hookBaseUrl}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), - }); - const createData = await createRes.json(); - expect(createData.success).toBe(true); - const sessionId = createData.session.id; - createdSessions.push(sessionId); - - // Post permission_prompt hook event - const hookRes = await fetch(`${hookBaseUrl}/api/hook-event`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - event: 'permission_prompt', - sessionId, - data: { message: 'Test permission prompt' }, - }), - }); - expect(hookRes.ok).toBe(true); - - const hookData = await hookRes.json(); - expect(hookData.success).toBe(true); - }); - - it('should accept idle_prompt hook events', async () => { - const sessionId = createdSessions[0] || 'test-session'; - - const hookRes = await fetch(`${hookBaseUrl}/api/hook-event`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - event: 'idle_prompt', - sessionId, - data: {}, - }), - }); - expect(hookRes.ok).toBe(true); - }); -}); - -describe('Ralph API', () => { - let server: WebServer; - const TEST_PORT_RALPH = 3153; - const ralphBaseUrl = `http://localhost:${TEST_PORT_RALPH}`; - let createdSessions: string[] = []; - - beforeAll(async () => { - server = new WebServer(TEST_PORT_RALPH, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - }, 30000); - - afterAll(async () => { - for (const sessionId of createdSessions) { - try { - await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - await server.stop(); - }, 60000); - - it('should enable Ralph tracking via API', async () => { - // Create a session - const createRes = await fetch(`${ralphBaseUrl}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), - }); - const createData = await createRes.json(); - expect(createData.success).toBe(true); - const sessionId = createData.session.id; - createdSessions.push(sessionId); - - // Enable Ralph tracking - const configRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: true }), - }); - expect(configRes.ok).toBe(true); - - // Get Ralph state - const stateRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-state`); - expect(stateRes.ok).toBe(true); - - const stateData = await stateRes.json(); - expect(stateData.success).toBe(true); - expect(stateData.data).toBeDefined(); - expect(stateData.data.loop).toBeDefined(); - expect(stateData.data.todos).toBeDefined(); - }); - - it('should configure completion phrase via API', async () => { - const sessionId = createdSessions[0]; - if (!sessionId) return; - - const configRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ completionPhrase: 'DONE' }), - }); - expect(configRes.ok).toBe(true); - }); - - it('should reset Ralph state via API', async () => { - const sessionId = createdSessions[0]; - if (!sessionId) return; - - const resetRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reset: true }), - }); - expect(resetRes.ok).toBe(true); - }); -}); diff --git a/test/browser-playwright.test.ts b/test/browser-playwright.test.ts deleted file mode 100644 index 829bde2d..00000000 --- a/test/browser-playwright.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Playwright Direct API Test Suite - * - * Tests Claudeman web UI using Playwright's native API directly. - * This provides the most control and best debugging capabilities. - * - * Port: 3155 (see CLAUDE.md test port table) - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { chromium, Browser, Page, BrowserContext } from 'playwright'; -import { WebServer } from '../src/web/server.js'; - -const TEST_PORT = 3155; -const BASE_URL = `http://localhost:${TEST_PORT}`; - -// Metrics collection -interface TestMetrics { - testName: string; - duration: number; - framework: 'playwright'; - success: boolean; - error?: string; -} - -const metrics: TestMetrics[] = []; -let server: WebServer; -let browser: Browser; -let createdSessions: string[] = []; -let setupComplete = false; - -describe('Playwright Direct API Tests', () => { - beforeAll(async () => { - // Start web server - server = new WebServer(TEST_PORT, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - - // Launch browser - browser = await chromium.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - }); - setupComplete = true; - }, 30000); - - afterAll(async () => { - // Cleanup sessions - for (const sessionId of createdSessions) { - try { - await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - - // Close browser and server - if (browser) await browser.close(); - if (server) await server.stop(); - - // Output metrics - console.log('\n📊 Playwright Test Metrics:'); - console.table(metrics.map(m => ({ - test: m.testName.substring(0, 40), - duration: `${m.duration}ms`, - status: m.success ? '✅' : '❌', - }))); - }, 30000); - - // Helper to run test with metrics - async function withMetrics(name: string, fn: (page: Page) => Promise) { - if (!setupComplete) { - console.log('Skipping - setup not complete'); - return; - } - const start = Date.now(); - let success = false; - let error: string | undefined; - const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); - const page = await context.newPage(); - try { - await fn(page); - success = true; - } catch (e: any) { - error = e.message; - throw e; - } finally { - await context.close(); - metrics.push({ testName: name, duration: Date.now() - start, framework: 'playwright', success, error }); - } - } - - // ============================================================================ - // Core Page Tests - // ============================================================================ - - it('should load the main page with correct title', async () => { - await withMetrics('page-load', async (page) => { - await page.goto(BASE_URL); - await expect(page).toHaveTitle('Claudeman'); - }); - }, 30000); - - it('should display the logo and branding', async () => { - await withMetrics('logo-branding', async (page) => { - await page.goto(BASE_URL); - const logo = page.locator('.header-brand .logo'); - await expect(logo).toBeVisible(); - await expect(logo).toHaveText('Claudeman'); - }); - }, 30000); - - it('should show version in toolbar', async () => { - await withMetrics('version-display', async (page) => { - await page.goto(BASE_URL); - const version = page.locator('#versionDisplay'); - await expect(version).toBeVisible(); - await expect(version).toHaveText(/v\d+\.\d+/); - }); - }, 30000); - - // ============================================================================ - // Modal Tests - // ============================================================================ - - it('should open and close help modal', async () => { - await withMetrics('help-modal', async (page) => { - await page.goto(BASE_URL); - await page.click('.help-btn'); - const modal = page.locator('#helpModal .modal-content'); - await expect(modal).toBeVisible(); - const heading = page.locator('#helpModal h3'); - await expect(heading).toContainText('Keyboard Shortcuts'); - await page.click('#helpModal .modal-close'); - await expect(modal).not.toBeVisible(); - }); - }, 30000); - - it('should open settings modal with 4 tabs', async () => { - await withMetrics('settings-modal', async (page) => { - await page.goto(BASE_URL); - await page.click('.btn-settings'); - const modal = page.locator('#appSettingsModal .modal-content'); - await expect(modal).toBeVisible(); - const tabs = page.locator('#appSettingsModal .modal-tab-btn'); - await expect(tabs).toHaveCount(4); - await page.click('#appSettingsModal .modal-close'); - }); - }, 30000); - - // ============================================================================ - // Session Management Tests - // ============================================================================ - - it('should create a new session via button click', async () => { - await withMetrics('session-creation', async (page) => { - await page.goto(BASE_URL); - await page.click('.btn-claude'); - const sessionTab = page.locator('.session-tab'); - await expect(sessionTab.first()).toBeVisible({ timeout: 15000 }); - await expect(page.locator('.session-tab.active')).toBeVisible(); - const terminal = page.locator('.xterm'); - await expect(terminal).toBeVisible({ timeout: 10000 }); - - // Track session for cleanup - const response = await fetch(`${BASE_URL}/api/sessions`); - const data = await response.json(); - if (data.sessions?.length > 0) { - const lastSession = data.sessions[data.sessions.length - 1]; - if (lastSession && !createdSessions.includes(lastSession.id)) { - createdSessions.push(lastSession.id); - } - } - }); - }, 60000); - - // ============================================================================ - // Stress Tests - // ============================================================================ - - it('should handle rapid modal interactions', async () => { - await withMetrics('rapid-interactions', async (page) => { - await page.goto(BASE_URL); - for (let i = 0; i < 5; i++) { - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { state: 'visible' }); - await page.click('#helpModal .modal-close'); - await page.waitForSelector('#helpModal .modal-content', { state: 'hidden' }); - } - await expect(page.locator('.header')).toBeVisible(); - }); - }, 30000); -}); - -// Export metrics for comparison -export { metrics as playwrightMetrics }; diff --git a/test/browser-puppeteer.test.ts b/test/browser-puppeteer.test.ts deleted file mode 100644 index a26dad96..00000000 --- a/test/browser-puppeteer.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -/** - * Puppeteer/Chrome Test Suite - * - * Tests Claudeman web UI using Puppeteer for direct Chrome DevTools Protocol access. - * Puppeteer provides lower-level Chrome control compared to Playwright. - * - * Port: 3156 (see CLAUDE.md test port table) - */ - -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import puppeteer, { Browser, Page } from 'puppeteer'; -import { WebServer } from '../src/web/server.js'; - -const TEST_PORT = 3156; -const BASE_URL = `http://localhost:${TEST_PORT}`; - -// Metrics collection -interface TestMetrics { - testName: string; - duration: number; - framework: 'puppeteer'; - success: boolean; - error?: string; -} - -const metrics: TestMetrics[] = []; - -describe('Puppeteer/Chrome Tests', () => { - let server: WebServer; - let browser: Browser; - let page: Page; - let createdSessions: string[] = []; - let testStartTime: number; - - beforeAll(async () => { - // Start web server - server = new WebServer(TEST_PORT, false, true); - await server.start(); - await new Promise(r => setTimeout(r, 1000)); - - // Launch Chrome via Puppeteer - browser = await puppeteer.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - ], - }); - }, 60000); - - afterAll(async () => { - // Cleanup sessions - for (const sessionId of createdSessions) { - try { - await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } - } - - // Close browser and server - if (browser) await browser.close(); - await server.stop(); - - // Output metrics - console.log('\n📊 Puppeteer Test Metrics:'); - console.table(metrics.map(m => ({ - test: m.testName.substring(0, 40), - duration: `${m.duration}ms`, - status: m.success ? '✅' : '❌', - }))); - }, 60000); - - beforeEach(async () => { - page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 720 }); - testStartTime = Date.now(); - }); - - afterEach(async () => { - const testInfo = expect.getState(); - metrics.push({ - testName: testInfo.currentTestName || 'unknown', - duration: Date.now() - testStartTime, - framework: 'puppeteer', - success: !testInfo.assertionCalls?.some((c: any) => !c.pass), - }); - - if (page) await page.close(); - }); - - // ============================================================================ - // Core Page Tests - // ============================================================================ - - it('should load the main page with correct title', async () => { - await page.goto(BASE_URL); - const title = await page.title(); - expect(title).toBe('Claudeman'); - }); - - it('should display the logo and branding', async () => { - await page.goto(BASE_URL); - - await page.waitForSelector('.header-brand .logo'); - const logoText = await page.$eval('.header-brand .logo', el => el.textContent); - expect(logoText).toBe('Claudeman'); - }); - - it('should show version in toolbar', async () => { - await page.goto(BASE_URL); - - await page.waitForSelector('#versionDisplay'); - const version = await page.$eval('#versionDisplay', el => el.textContent); - expect(version).toMatch(/v\d+\.\d+/); - }); - - // ============================================================================ - // Button & Control Tests - // ============================================================================ - - it('should have all main control buttons visible', async () => { - await page.goto(BASE_URL); - - const btnClaude = await page.$('.btn-claude'); - const btnSettings = await page.$('.btn-settings'); - const helpBtn = await page.$('.help-btn'); - const quickStart = await page.$('#quickStartCase'); - - expect(btnClaude).not.toBeNull(); - expect(btnSettings).not.toBeNull(); - expect(helpBtn).not.toBeNull(); - expect(quickStart).not.toBeNull(); - }); - - it('should have font size controls', async () => { - await page.goto(BASE_URL); - - const fontControls = await page.$('.header-font-controls'); - expect(fontControls).not.toBeNull(); - - const fontBtns = await page.$$('.header-font-controls .font-btn'); - expect(fontBtns.length).toBeGreaterThanOrEqual(2); - }); - - // ============================================================================ - // Modal Tests - // ============================================================================ - - it('should open and close help modal', async () => { - await page.goto(BASE_URL); - - // Open help modal - await page.click('.help-btn'); - await page.waitForSelector('#helpModal .modal-content', { visible: true }); - - // Check content - const heading = await page.$eval('#helpModal h3', el => el.textContent); - expect(heading).toContain('Keyboard Shortcuts'); - - // Check modal is visible - const isVisible = await page.$eval('#helpModal .modal-content', el => { - const style = window.getComputedStyle(el); - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - expect(isVisible).toBe(true); - - // Close modal - await page.click('#helpModal .modal-close'); - await page.waitForFunction(() => { - const modal = document.querySelector('#helpModal .modal-content'); - if (!modal) return true; - const style = window.getComputedStyle(modal); - return style.display === 'none' || style.visibility === 'hidden'; - }, { timeout: 5000 }).catch(() => {}); - }); - - it('should open settings modal with 4 tabs', async () => { - await page.goto(BASE_URL); - - // Open settings - await page.click('.btn-settings'); - await page.waitForSelector('#appSettingsModal .modal-content', { visible: true }); - - // Check tabs - const tabs = await page.$$('#appSettingsModal .modal-tab-btn'); - expect(tabs.length).toBe(4); - - // Verify tab names - const tabTexts = await page.$$eval('#appSettingsModal .modal-tab-btn', els => - els.map(el => el.textContent?.trim()) - ); - expect(tabTexts).toContain('Display'); - expect(tabTexts).toContain('Claude CLI'); - - // Close - await page.click('#appSettingsModal .modal-close'); - }); - - it('should switch between settings tabs', async () => { - await page.goto(BASE_URL); - - await page.click('.btn-settings'); - await page.waitForSelector('#appSettingsModal .modal-content', { visible: true }); - - // Click each tab - const tabs = await page.$$('#appSettingsModal .modal-tab-btn'); - - for (let i = 0; i < tabs.length; i++) { - await tabs[i].click(); - await new Promise(r => setTimeout(r, 100)); - - // Check tab is active - const isActive = await page.$eval( - `#appSettingsModal .modal-tab-btn:nth-child(${i + 1})`, - el => el.classList.contains('active') - ); - expect(isActive).toBe(true); - } - - await page.click('#appSettingsModal .modal-close'); - }); - - // ============================================================================ - // Session Management Tests - // ============================================================================ - - it('should create a new session via button click', async () => { - await page.goto(BASE_URL); - - // Click create session button - await page.click('.btn-claude'); - - // Wait for session tab to appear - await page.waitForSelector('.session-tab', { timeout: 15000 }); - - // Tab should be active - const hasActive = await page.$('.session-tab.active'); - expect(hasActive).not.toBeNull(); - - // Terminal should be visible - await page.waitForSelector('.xterm', { timeout: 10000 }); - const terminal = await page.$('.xterm'); - expect(terminal).not.toBeNull(); - - // Track session for cleanup - const response = await fetch(`${BASE_URL}/api/sessions`); - const data = await response.json(); - if (data.sessions?.length > 0) { - const lastSession = data.sessions[data.sessions.length - 1]; - if (lastSession && !createdSessions.includes(lastSession.id)) { - createdSessions.push(lastSession.id); - } - } - }, 30000); - - it('should show tab count correctly', async () => { - await page.goto(BASE_URL); - - // Get initial count - const initialText = await page.$eval('#tabCount', el => el.textContent); - const initialCount = parseInt(initialText?.match(/\d+/)?.[0] || '0'); - - // Create a session - await page.click('.btn-claude'); - await page.waitForSelector('.session-tab', { timeout: 15000 }); - - // Tab count should increase - await page.waitForFunction( - (expected: number) => { - const el = document.querySelector('#tabCount'); - const text = el?.textContent || ''; - const match = text.match(/\d+/); - return match && parseInt(match[0]) >= expected; - }, - { timeout: 5000 }, - initialCount + 1 - ); - - // Track for cleanup - const response = await fetch(`${BASE_URL}/api/sessions`); - const data = await response.json(); - data.sessions?.forEach((s: any) => { - if (!createdSessions.includes(s.id)) createdSessions.push(s.id); - }); - }, 30000); - - // ============================================================================ - // Terminal Tests - // ============================================================================ - - it('should initialize xterm terminal with correct dimensions', async () => { - await page.goto(BASE_URL); - - // Create session - await page.click('.btn-claude'); - await page.waitForSelector('.xterm', { timeout: 15000 }); - - // Check terminal container exists - const terminal = await page.$('.xterm'); - expect(terminal).not.toBeNull(); - - // Check xterm canvas exists (indicates proper initialization) - const canvas = await page.$('.xterm-screen'); - expect(canvas).not.toBeNull(); - - // Track for cleanup - const response = await fetch(`${BASE_URL}/api/sessions`); - const data = await response.json(); - data.sessions?.forEach((s: any) => { - if (!createdSessions.includes(s.id)) createdSessions.push(s.id); - }); - }, 30000); - - // ============================================================================ - // Keyboard Shortcut Tests - // ============================================================================ - - it('should respond to Ctrl+Enter for quick start', async () => { - await page.goto(BASE_URL); - - const initialSessions = await fetch(`${BASE_URL}/api/sessions`).then(r => r.json()); - const initialCount = initialSessions.sessions?.length || 0; - - // Press Ctrl+Enter - await page.keyboard.down('Control'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Control'); - - // Wait for session to be created - await page.waitForSelector('.session-tab', { timeout: 15000 }); - - // Verify session was created - const afterSessions = await fetch(`${BASE_URL}/api/sessions`).then(r => r.json()); - expect(afterSessions.sessions?.length).toBeGreaterThan(initialCount); - - // Track for cleanup - afterSessions.sessions?.forEach((s: any) => { - if (!createdSessions.includes(s.id)) createdSessions.push(s.id); - }); - }, 30000); - - // ============================================================================ - // Chrome DevTools Protocol Direct Access - // ============================================================================ - - it('should access CDP for performance metrics', async () => { - await page.goto(BASE_URL); - - // Get performance metrics via CDP - const client = await page.target().createCDPSession(); - await client.send('Performance.enable'); - - const { metrics: perfMetrics } = await client.send('Performance.getMetrics'); - - // Should have some metrics - expect(perfMetrics.length).toBeGreaterThan(0); - - // Find specific metrics - const jsHeapSize = perfMetrics.find(m => m.name === 'JSHeapUsedSize'); - expect(jsHeapSize).toBeDefined(); - }); - - it('should capture network activity via CDP', async () => { - const networkRequests: string[] = []; - - const client = await page.target().createCDPSession(); - await client.send('Network.enable'); - - client.on('Network.requestWillBeSent', (params) => { - networkRequests.push(params.request.url); - }); - - await page.goto(BASE_URL); - await new Promise(r => setTimeout(r, 2000)); - - // Should have captured some requests - expect(networkRequests.length).toBeGreaterThan(0); - expect(networkRequests.some(url => url.includes('/api/events'))).toBe(true); - }); - - // ============================================================================ - // Stress Tests - // ============================================================================ - - it('should handle rapid button clicks without errors', async () => { - await page.goto(BASE_URL); - - // Rapidly click help button multiple times - for (let i = 0; i < 5; i++) { - await page.click('.help-btn'); - await new Promise(r => setTimeout(r, 100)); - const closeBtn = await page.$('#helpModal .modal-close'); - if (closeBtn) await closeBtn.click().catch(() => {}); - await new Promise(r => setTimeout(r, 100)); - } - - // Page should still be functional - const header = await page.$('.header'); - expect(header).not.toBeNull(); - }); - - // ============================================================================ - // Screenshot & Visual Tests - // ============================================================================ - - it('should capture screenshot for visual inspection', async () => { - await page.goto(BASE_URL); - - // Take a screenshot (could be used for visual regression) - const screenshot = await page.screenshot({ encoding: 'base64' }); - expect(screenshot.length).toBeGreaterThan(0); - }); - - // ============================================================================ - // Console & Error Monitoring - // ============================================================================ - - it('should not have console errors on page load', async () => { - const consoleErrors: string[] = []; - - page.on('console', msg => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - - page.on('pageerror', err => { - consoleErrors.push(err.message); - }); - - await page.goto(BASE_URL); - await new Promise(r => setTimeout(r, 2000)); - - // Filter out expected errors (like SSE reconnection) - const unexpectedErrors = consoleErrors.filter(e => - !e.includes('net::ERR') && !e.includes('SSE') - ); - - expect(unexpectedErrors.length).toBe(0); - }); -}); - -// Export metrics for comparison -export { metrics as puppeteerMetrics }; diff --git a/test/e2e/workflows/mobile-comprehensive.e2e.ts b/test/e2e/workflows/mobile-comprehensive.e2e.ts deleted file mode 100644 index f304626e..00000000 --- a/test/e2e/workflows/mobile-comprehensive.e2e.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Comprehensive Mobile E2E Tests - * Tests mobile-specific UI features, interactions, and usability - * - * Port: 3192 (unique for this test file) - */ - -import { describe, it, expect, afterAll, beforeAll } from 'vitest'; -import { - createServerFixture, - destroyServerFixture, - createMobileSafariFixture, - destroyMobileBrowserFixture, - hasBodyClass, - getMinHeight, - tap, - getComputedStyle, - CleanupTracker, - type ServerFixture, - type MobileBrowserFixture, -} from '../fixtures/index.js'; -import { E2E_TIMEOUTS, MOBILE_VIEWPORTS, generateCaseName, E2E_PORTS } from '../e2e.config.js'; - -const PORT = E2E_PORTS.MOBILE_COMPREHENSIVE; -let serverFixture: ServerFixture | null = null; -let cleanup: CleanupTracker; - -describe('Mobile Comprehensive E2E', () => { - beforeAll(async () => { - serverFixture = await createServerFixture(PORT); - cleanup = new CleanupTracker(serverFixture.baseUrl); - }, E2E_TIMEOUTS.BROWSER_SETUP); - - afterAll(async () => { - if (cleanup) { - await cleanup.forceCleanupAll(); - } - if (serverFixture) { - await destroyServerFixture(serverFixture); - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Welcome Overlay Tests - // ============================================================================ - - it('should show welcome overlay with touch-friendly buttons on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Welcome overlay should be visible - const overlayVisible = await page.isVisible('.welcome-overlay'); - expect(overlayVisible).toBe(true); - - // Welcome buttons should have touch-friendly size - const runClaudeBtn = await page.$('.welcome-btn-primary'); - expect(runClaudeBtn).not.toBeNull(); - - const box = await runClaudeBtn!.boundingBox(); - expect(box).not.toBeNull(); - // Touch target should be at least 44px tall - expect(box!.height).toBeGreaterThanOrEqual(44); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Toolbar Tests - // ============================================================================ - - it('should show Run Claude button prominently on mobile toolbar', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Run Claude button should be visible - const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude'); - expect(runBtnVisible).toBe(true); - - // Button should have flex: 1 on mobile (takes up space) - const flexValue = await getComputedStyle(page, '.btn-toolbar.btn-claude', 'flex'); - // flex: 1 computes to "1 1 0%" or similar - expect(flexValue).toContain('1'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should show case dropdown on mobile toolbar', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Case dropdown should be visible - const dropdownVisible = await page.isVisible('#quickStartCase'); - expect(dropdownVisible).toBe(true); - - // Should be able to interact with it - const options = await page.$$eval('#quickStartCase option', (opts) => - opts.map((o) => (o as HTMLOptionElement).value) - ); - expect(options.length).toBeGreaterThan(0); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should hide version display on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Toolbar center (version) should be hidden on phones - const centerVisible = await page.isVisible('.toolbar-center'); - expect(centerVisible).toBe(false); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Session Tab Tests - // ============================================================================ - - it('should allow horizontal scrolling of session tabs on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('mobile-scroll'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - - // Create multiple sessions to enable scrolling - for (let i = 0; i < 3; i++) { - await tap(page, '.btn-toolbar.btn-claude'); - await page.waitForSelector('.session-tab', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - } - - // Check that session tabs container allows horizontal scroll - const overflowX = await getComputedStyle(page, '.session-tabs', 'overflow-x'); - expect(overflowX).toBe('auto'); - - // Check scrollbar is hidden (webkit) - const scrollbarWidth = await page.$eval('.session-tabs', (el) => { - const style = window.getComputedStyle(el, '::-webkit-scrollbar'); - return style.display; - }); - // Should be 'none' due to scrollbar-width: none - - // Track sessions for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should switch between session tabs on tap', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('mobile-switch'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - - // Create 2 sessions - await tap(page, '.btn-toolbar.btn-claude'); - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - - await tap(page, '.btn-toolbar.btn-claude'); - await page.waitForTimeout(1000); - - // Get the tabs - should have at least 2 - const tabs = await page.$$('.session-tab'); - expect(tabs.length).toBeGreaterThanOrEqual(2); - - // Tap the first tab - await tap(page, '.session-tab:first-child'); - await page.waitForTimeout(300); - - // First tab should now be active - const firstTabActive = await page.$eval('.session-tab:first-child', (el) => - el.classList.contains('active') - ); - expect(firstTabActive).toBe(true); - - // Track sessions for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Modal Tests - // ============================================================================ - - it('should show full-screen modals on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Open app settings modal - await tap(page, '.btn-settings'); - await page.waitForSelector('#appSettingsModal.open, #appSettingsModal:not(.hidden)', { timeout: 5000 }); - await page.waitForTimeout(300); - - // Modal content should be full-width on mobile - const modalWidth = await page.$eval('#appSettingsModal .modal-content', (el) => - el.getBoundingClientRect().width - ); - const viewportWidth = page.viewportSize()!.width; - - // Modal should be full width (or very close to it) - expect(modalWidth).toBeGreaterThanOrEqual(viewportWidth - 2); - - // Close modal - await tap(page, '#appSettingsModal .modal-close'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should show create case modal on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Tap the + button to create case - await tap(page, '.btn-case-add'); - await page.waitForSelector('#createCaseModal', { state: 'visible', timeout: 5000 }); - - // Modal should be visible - const modalVisible = await page.isVisible('#createCaseModal'); - expect(modalVisible).toBe(true); - - // Input should be focusable - const nameInput = await page.$('#newCaseName'); - expect(nameInput).not.toBeNull(); - - // Close modal - await tap(page, '#createCaseModal .modal-close'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Header Tests - // ============================================================================ - - it('should show notification button on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Notification button should be visible - const notifBtnVisible = await page.isVisible('.btn-notifications'); - expect(notifBtnVisible).toBe(true); - - // Should be tappable - await tap(page, '.btn-notifications'); - await page.waitForTimeout(300); - - // Notification drawer should open - const drawerVisible = await page.isVisible('#notifDrawer'); - expect(drawerVisible).toBe(true); - - // Close drawer - await tap(page, '.btn-notifications'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should show settings button on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Settings button should be visible - const settingsBtnVisible = await page.isVisible('.btn-settings'); - expect(settingsBtnVisible).toBe(true); - - // Button should have adequate touch target - const minHeight = await getMinHeight(page, '.btn-settings'); - expect(minHeight).toBeGreaterThanOrEqual(44); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Close Session Tests - // ============================================================================ - - it('should show close confirmation modal on tap', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('mobile-close'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - - // Tap the close button on the tab - await tap(page, '.session-tab .tab-close'); - await page.waitForTimeout(300); - - // Close confirmation modal should appear - const modalVisible = await page.isVisible('#closeConfirmModal'); - expect(modalVisible).toBe(true); - - // Cancel to keep session - await tap(page, '#closeConfirmModal .modal-footer-cancel button'); - - // Track session for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Token Display Tests - // ============================================================================ - - it('should show token count on mobile header', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Token count should be visible (default showTokenCount is true) - // Note: It might be in .header-tokens - const tokenDisplayExists = await page.$('.header-tokens'); - expect(tokenDisplayExists).not.toBeNull(); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Ralph Wizard Tests - // ============================================================================ - - it('should show Ralph wizard full-screen on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - // Clean up any existing sessions first (from previous tests) - const sessionsRes = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const existingSessions = await sessionsRes.json(); - for (const session of existingSessions || []) { - await fetch(`${serverFixture!.baseUrl}/api/sessions/${session.id}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ killScreen: true }), - }); - } - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Wait for welcome overlay to be visible (no active sessions) - await page.waitForSelector('.welcome-overlay.visible', { timeout: 5000 }); - - // Tap the Ralph wizard button in welcome overlay - await tap(page, '.welcome-btn-ralph'); - await page.waitForSelector('#ralphWizardModal', { state: 'visible', timeout: 5000 }); - - // Modal should be visible - const modalVisible = await page.isVisible('#ralphWizardModal'); - expect(modalVisible).toBe(true); - - // Modal should be full-screen on mobile - const modalWidth = await page.$eval('#ralphWizardModal .modal-content', (el) => - el.getBoundingClientRect().width - ); - const viewportWidth = page.viewportSize()!.width; - expect(modalWidth).toBeGreaterThanOrEqual(viewportWidth - 2); - - // Close wizard - await tap(page, '#ralphWizardModal .modal-close'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); -}); diff --git a/test/e2e/workflows/mobile-edge-cases.e2e.ts b/test/e2e/workflows/mobile-edge-cases.e2e.ts deleted file mode 100644 index 68b9d190..00000000 --- a/test/e2e/workflows/mobile-edge-cases.e2e.ts +++ /dev/null @@ -1,630 +0,0 @@ -/** - * Mobile Edge Cases E2E Tests - * Tests additional mobile edge cases: orientation, gestures, modals, input handling - * - * Port: 3193 (unique for this test file) - */ - -import { describe, it, expect, afterAll, beforeAll } from 'vitest'; -import { - CleanupTracker, - createMobileSafariFixture, - createServerFixture, - destroyMobileBrowserFixture, - destroyServerFixture, - getComputedStyle, - hasBodyClass, - tap, - type MobileBrowserFixture, - type ServerFixture, -} from '../fixtures/index.js'; -import { E2E_PORTS, E2E_TIMEOUTS, MOBILE_VIEWPORTS, generateCaseName } from '../e2e.config.js'; - -const PORT = E2E_PORTS.MOBILE_EDGE_CASES; -let serverFixture: ServerFixture | null = null; -let cleanup: CleanupTracker; - -describe('Mobile Edge Cases E2E', () => { - beforeAll(async () => { - serverFixture = await createServerFixture(PORT); - cleanup = new CleanupTracker(serverFixture.baseUrl); - }, E2E_TIMEOUTS.BROWSER_SETUP); - - afterAll(async () => { - if (cleanup) { - await cleanup.forceCleanupAll(); - } - if (serverFixture) { - await destroyServerFixture(serverFixture); - } - }, E2E_TIMEOUTS.TEST); - - // ============================================================================ - // Orientation Tests - // ============================================================================ - - describe('Landscape Orientation', () => { - it('should handle landscape phone orientation correctly', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO_LANDSCAPE); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Verify viewport is landscape - const viewport = page.viewportSize(); - expect(viewport?.width).toBeGreaterThan(viewport?.height ?? 0); - - // In landscape, width is 874px which is > 768px, so should be desktop class - const hasDesktopClass = await hasBodyClass(page, 'device-desktop'); - expect(hasDesktopClass).toBe(true); - - // But should still have touch class - const hasTouchClass = await hasBodyClass(page, 'touch-device'); - expect(hasTouchClass).toBe(true); - - // Header brand should be visible in landscape (>430px) - const brandVisible = await page.isVisible('.header-brand'); - expect(brandVisible).toBe(true); - - // Run Claude button should still be accessible - const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude'); - expect(runBtnVisible).toBe(true); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should show terminal properly in landscape mode', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('landscape-terminal'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO_LANDSCAPE); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - - // Terminal should be visible and properly sized - const terminalVisible = await page.isVisible('.terminal-container'); - expect(terminalVisible).toBe(true); - - // Terminal should have reasonable height in landscape - const terminalBox = await page.$eval('.terminal-container', (el) => - el.getBoundingClientRect() - ); - expect(terminalBox.height).toBeGreaterThan(100); - - // Track for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Narrow Screen Tests (Galaxy Fold Folded) - // ============================================================================ - - describe('Very Narrow Screens', () => { - it('should handle extremely narrow viewport (280px)', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.GALAXY_FOLD_FOLDED); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Should be device-mobile class - const hasMobileClass = await hasBodyClass(page, 'device-mobile'); - expect(hasMobileClass).toBe(true); - - // Run Claude button should still be visible and usable - const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude'); - expect(runBtnVisible).toBe(true); - - // Button should be tappable (has adequate size) - const btnBox = await page.$eval('.btn-toolbar.btn-claude', (el) => - el.getBoundingClientRect() - ); - expect(btnBox.width).toBeGreaterThan(40); - expect(btnBox.height).toBeGreaterThanOrEqual(44); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should keep toolbar usable on very narrow screens', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.GALAXY_FOLD_FOLDED); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Toolbar should be visible - const toolbarVisible = await page.isVisible('.toolbar'); - expect(toolbarVisible).toBe(true); - - // Toolbar should not overflow the viewport horizontally - const toolbarBox = await page.$eval('.toolbar', (el) => - el.getBoundingClientRect() - ); - const viewportWidth = page.viewportSize()!.width; - - // Allow some tolerance for padding - expect(toolbarBox.width).toBeLessThanOrEqual(viewportWidth + 10); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Session Options Modal Tests - // ============================================================================ - - describe('Session Options Modal', () => { - it('should open session options modal via gear icon tap', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('session-options'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - - // Tap the gear icon on the session tab (should be visible on touch devices) - const gearVisible = await page.isVisible('.session-tab .tab-gear'); - expect(gearVisible).toBe(true); - - await tap(page, '.session-tab .tab-gear'); - await page.waitForTimeout(500); - - // Session options modal should open - const modalVisible = await page.isVisible('#sessionOptionsModal'); - expect(modalVisible).toBe(true); - - // Modal should be full-screen on mobile - const modalWidth = await page.$eval('#sessionOptionsModal .modal-content', (el) => - el.getBoundingClientRect().width - ); - const viewportWidth = page.viewportSize()!.width; - expect(modalWidth).toBeGreaterThanOrEqual(viewportWidth - 2); - - // Close modal - await tap(page, '#sessionOptionsModal .modal-close'); - - // Track for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should show modal tabs and allow switching on mobile', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('modal-tabs'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - - // Open session options modal - await tap(page, '.session-tab .tab-gear'); - await page.waitForSelector('#sessionOptionsModal', { state: 'visible', timeout: 5000 }); - await page.waitForTimeout(300); - - // Modal tabs should exist - const tabs = await page.$$('#sessionOptionsModal .modal-tab-btn'); - expect(tabs.length).toBeGreaterThan(0); - - // First tab should be active by default - const firstTabActive = await page.$eval('#sessionOptionsModal .modal-tab-btn:first-child', (el) => - el.classList.contains('active') - ); - expect(firstTabActive).toBe(true); - - // Tap second tab if it exists - if (tabs.length > 1) { - await tap(page, '#sessionOptionsModal .modal-tab-btn:nth-child(2)'); - await page.waitForTimeout(300); - - // Second tab should now be active - const secondTabActive = await page.$eval('#sessionOptionsModal .modal-tab-btn:nth-child(2)', (el) => - el.classList.contains('active') - ); - expect(secondTabActive).toBe(true); - } - - // Close modal - await tap(page, '#sessionOptionsModal .modal-close'); - - // Track for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Terminal Interaction Tests - // ============================================================================ - - describe('Terminal Interactions', () => { - it('should show terminal properly on tap', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('terminal-focus'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(1000); - - // Terminal container should be visible - const terminalVisible = await page.isVisible('.terminal-container'); - expect(terminalVisible).toBe(true); - - // Tap on terminal area - const terminalBox = await page.$eval('.terminal-container', (el) => - el.getBoundingClientRect() - ); - await page.touchscreen.tap( - terminalBox.x + terminalBox.width / 2, - terminalBox.y + terminalBox.height / 2 - ); - await page.waitForTimeout(300); - - // Terminal should still be visible after tap - const stillVisible = await page.isVisible('.terminal-container'); - expect(stillVisible).toBe(true); - - // Track for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Touch Target Size Tests - // ============================================================================ - - describe('Touch Target Sizes', () => { - it('should have adequate touch targets for primary interactive elements', async () => { - let browser: MobileBrowserFixture | null = null; - const caseName = generateCaseName('touch-targets'); - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - cleanup.trackCase(caseName); - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create case and session to see all UI elements - await fetch(`${serverFixture!.baseUrl}/api/cases`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName }), - }); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.selectOption('#quickStartCase', caseName); - await tap(page, '.btn-toolbar.btn-claude'); - - await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE }); - await page.waitForTimeout(500); - - // Check primary interactive elements for minimum touch target size - // These are the most important buttons that must meet 44px requirement - const primaryElements = [ - '.btn-toolbar.btn-claude', - '.btn-settings', - '.btn-notifications', - ]; - - for (const selector of primaryElements) { - const exists = await page.$(selector); - if (exists) { - const box = await page.$eval(selector, (el) => el.getBoundingClientRect()); - // Height should meet 44px minimum for primary buttons - expect(box.height).toBeGreaterThanOrEqual(44); - } - } - - // Track for cleanup - const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`); - const data = await response.json(); - data?.forEach((s: { id: string }) => cleanup.trackSession(s.id)); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // iOS Specific Tests - // ============================================================================ - - describe('iOS Safe Areas', () => { - it('should have safe area CSS variables defined', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Check that safe area CSS variables are defined in the stylesheet - // We check by evaluating in page context with a simple string - const hasSafeAreaStyles = await page.evaluate(` - (function() { - const style = window.getComputedStyle(document.documentElement); - const top = style.getPropertyValue('--safe-area-top'); - const bottom = style.getPropertyValue('--safe-area-bottom'); - return top !== '' || bottom !== '' || true; - })() - `); - - expect(hasSafeAreaStyles).toBe(true); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - - it('should prevent double-tap zoom on interactive elements', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Buttons should have touch-action: manipulation to prevent double-tap zoom - const touchAction = await getComputedStyle(page, '.btn-toolbar.btn-claude', 'touch-action'); - // Should be 'manipulation' or 'auto' (browsers handle this differently) - expect(touchAction).toBeDefined(); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Scrollable Content Tests - // ============================================================================ - - describe('Scrollable Areas', () => { - it('should allow touch scrolling in modal body', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Open settings modal (has scrollable content) - await tap(page, '.btn-settings'); - await page.waitForSelector('#appSettingsModal', { state: 'visible', timeout: 5000 }); - await page.waitForTimeout(300); - - // Modal body should have overflow-y: auto for scrolling - const overflowY = await getComputedStyle(page, '#appSettingsModal .modal-body', 'overflow-y'); - expect(['auto', 'scroll']).toContain(overflowY); - - // Close modal - await tap(page, '#appSettingsModal .modal-close'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Viewport Meta Tests - // ============================================================================ - - describe('Viewport Configuration', () => { - it('should have proper viewport meta tag', async () => { - let browser: MobileBrowserFixture | null = null; - - try { - browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - - // Check viewport meta tag - const viewportContent = await page.$eval('meta[name="viewport"]', (el) => - el.getAttribute('content') - ); - - expect(viewportContent).toBeDefined(); - expect(viewportContent).toContain('width=device-width'); - expect(viewportContent).toContain('initial-scale=1'); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); - - // ============================================================================ - // Android-Specific Viewport Tests - // ============================================================================ - - describe('Android Viewport', () => { - it('should render correctly on Pixel viewport', async () => { - let browser: MobileBrowserFixture | null = null; - - // Pixel 7a viewport - const PIXEL_7A = { - width: 412, - height: 915, - deviceScaleFactor: 2.625, - isMobile: true, - hasTouch: true, - }; - - try { - browser = await createMobileSafariFixture(PIXEL_7A); - const { page } = browser; - - await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Should be device-mobile class (width 412px < 430px) - const hasMobileClass = await hasBodyClass(page, 'device-mobile'); - expect(hasMobileClass).toBe(true); - - // Touch device class should be set - const hasTouchClass = await hasBodyClass(page, 'touch-device'); - expect(hasTouchClass).toBe(true); - - // Run Claude button should be visible - const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude'); - expect(runBtnVisible).toBe(true); - - } finally { - if (browser) { - await destroyMobileBrowserFixture(browser); - } - } - }, E2E_TIMEOUTS.TEST); - }); -}); diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts deleted file mode 100644 index 6e3f1af3..00000000 --- a/test/event-emitter.test.ts +++ /dev/null @@ -1,775 +0,0 @@ -/** - * @fileoverview Tests for event emitter patterns and utilities - * - * Tests event emission, subscription, and cleanup patterns used - * throughout the Claudeman application. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { EventEmitter } from 'node:events'; - -describe('Event Emitter Patterns', () => { - describe('Basic Event Emission', () => { - it('should emit and receive events', () => { - const emitter = new EventEmitter(); - const handler = vi.fn(); - emitter.on('test', handler); - emitter.emit('test', 'data'); - expect(handler).toHaveBeenCalledWith('data'); - }); - - it('should handle multiple listeners', () => { - const emitter = new EventEmitter(); - const handler1 = vi.fn(); - const handler2 = vi.fn(); - emitter.on('test', handler1); - emitter.on('test', handler2); - emitter.emit('test', 'data'); - expect(handler1).toHaveBeenCalledWith('data'); - expect(handler2).toHaveBeenCalledWith('data'); - }); - - it('should handle once listeners', () => { - const emitter = new EventEmitter(); - const handler = vi.fn(); - emitter.once('test', handler); - emitter.emit('test', 'first'); - emitter.emit('test', 'second'); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith('first'); - }); - - it('should remove listeners', () => { - const emitter = new EventEmitter(); - const handler = vi.fn(); - emitter.on('test', handler); - emitter.off('test', handler); - emitter.emit('test', 'data'); - expect(handler).not.toHaveBeenCalled(); - }); - - it('should remove all listeners', () => { - const emitter = new EventEmitter(); - const handler1 = vi.fn(); - const handler2 = vi.fn(); - emitter.on('test', handler1); - emitter.on('test', handler2); - emitter.removeAllListeners('test'); - emitter.emit('test', 'data'); - expect(handler1).not.toHaveBeenCalled(); - expect(handler2).not.toHaveBeenCalled(); - }); - }); - - describe('Typed Event Emitter Pattern', () => { - interface MyEvents { - data: (payload: string) => void; - error: (err: Error) => void; - count: (n: number) => void; - } - - class TypedEmitter extends EventEmitter { - emit(event: K, ...args: Parameters): boolean { - return super.emit(event, ...args); - } - - on(event: K, listener: MyEvents[K]): this { - return super.on(event, listener); - } - - off(event: K, listener: MyEvents[K]): this { - return super.off(event, listener); - } - } - - it('should emit typed events', () => { - const emitter = new TypedEmitter(); - const handler = vi.fn(); - emitter.on('data', handler); - emitter.emit('data', 'hello'); - expect(handler).toHaveBeenCalledWith('hello'); - }); - - it('should emit error events', () => { - const emitter = new TypedEmitter(); - const handler = vi.fn(); - emitter.on('error', handler); - const error = new Error('test error'); - emitter.emit('error', error); - expect(handler).toHaveBeenCalledWith(error); - }); - - it('should emit count events', () => { - const emitter = new TypedEmitter(); - const handler = vi.fn(); - emitter.on('count', handler); - emitter.emit('count', 42); - expect(handler).toHaveBeenCalledWith(42); - }); - }); - - describe('Event Queue Pattern', () => { - class EventQueue { - private queue: T[] = []; - private handlers: ((item: T) => void)[] = []; - private processing = false; - - enqueue(item: T): void { - this.queue.push(item); - this.process(); - } - - onItem(handler: (item: T) => void): void { - this.handlers.push(handler); - } - - private process(): void { - if (this.processing) return; - this.processing = true; - - while (this.queue.length > 0) { - const item = this.queue.shift()!; - for (const handler of this.handlers) { - handler(item); - } - } - - this.processing = false; - } - - get pending(): number { - return this.queue.length; - } - } - - it('should process items in order', () => { - const queue = new EventQueue(); - const items: string[] = []; - queue.onItem(item => items.push(item)); - - queue.enqueue('a'); - queue.enqueue('b'); - queue.enqueue('c'); - - expect(items).toEqual(['a', 'b', 'c']); - }); - - it('should handle multiple handlers', () => { - const queue = new EventQueue(); - let sum = 0; - let product = 1; - - queue.onItem(n => { sum += n; }); - queue.onItem(n => { product *= n; }); - - queue.enqueue(2); - queue.enqueue(3); - - expect(sum).toBe(5); - expect(product).toBe(6); - }); - - it('should report pending count', () => { - const queue = new EventQueue(); - // Items are processed immediately when handlers are added - expect(queue.pending).toBe(0); - }); - }); - - describe('Event Aggregation Pattern', () => { - class EventAggregator { - private events: T[] = []; - private maxEvents: number; - private onFlush: (events: T[]) => void; - private flushTimer: ReturnType | null = null; - private flushInterval: number; - - constructor(options: { - maxEvents: number; - flushInterval: number; - onFlush: (events: T[]) => void; - }) { - this.maxEvents = options.maxEvents; - this.flushInterval = options.flushInterval; - this.onFlush = options.onFlush; - } - - add(event: T): void { - this.events.push(event); - - if (this.events.length >= this.maxEvents) { - this.flush(); - } else if (!this.flushTimer) { - this.flushTimer = setTimeout(() => this.flush(), this.flushInterval); - } - } - - flush(): void { - if (this.flushTimer) { - clearTimeout(this.flushTimer); - this.flushTimer = null; - } - - if (this.events.length > 0) { - const events = this.events; - this.events = []; - this.onFlush(events); - } - } - - get pendingCount(): number { - return this.events.length; - } - } - - it('should flush when max events reached', () => { - const flushed: string[][] = []; - const aggregator = new EventAggregator({ - maxEvents: 3, - flushInterval: 1000, - onFlush: events => flushed.push(events), - }); - - aggregator.add('a'); - aggregator.add('b'); - expect(flushed).toHaveLength(0); - - aggregator.add('c'); - expect(flushed).toHaveLength(1); - expect(flushed[0]).toEqual(['a', 'b', 'c']); - }); - - it('should flush manually', () => { - const flushed: number[][] = []; - const aggregator = new EventAggregator({ - maxEvents: 100, - flushInterval: 10000, - onFlush: events => flushed.push(events), - }); - - aggregator.add(1); - aggregator.add(2); - aggregator.flush(); - - expect(flushed).toHaveLength(1); - expect(flushed[0]).toEqual([1, 2]); - }); - - it('should not flush when empty', () => { - const flushed: string[][] = []; - const aggregator = new EventAggregator({ - maxEvents: 10, - flushInterval: 1000, - onFlush: events => flushed.push(events), - }); - - aggregator.flush(); - expect(flushed).toHaveLength(0); - }); - - it('should track pending count', () => { - const aggregator = new EventAggregator({ - maxEvents: 10, - flushInterval: 1000, - onFlush: () => {}, - }); - - expect(aggregator.pendingCount).toBe(0); - aggregator.add('a'); - expect(aggregator.pendingCount).toBe(1); - aggregator.add('b'); - expect(aggregator.pendingCount).toBe(2); - aggregator.flush(); - expect(aggregator.pendingCount).toBe(0); - }); - }); - - describe('Event Filtering Pattern', () => { - class FilteredEmitter extends EventEmitter { - private filters: Map boolean)[]> = new Map(); - - addFilter(event: string, filter: (data: unknown) => boolean): void { - if (!this.filters.has(event)) { - this.filters.set(event, []); - } - this.filters.get(event)!.push(filter); - } - - emitFiltered(event: string, data: unknown): boolean { - const filters = this.filters.get(event) || []; - const shouldEmit = filters.every(f => f(data)); - if (shouldEmit) { - return this.emit(event, data); - } - return false; - } - - clearFilters(event?: string): void { - if (event) { - this.filters.delete(event); - } else { - this.filters.clear(); - } - } - } - - it('should emit when all filters pass', () => { - const emitter = new FilteredEmitter(); - const handler = vi.fn(); - - emitter.addFilter('data', (d) => (d as number) > 0); - emitter.on('data', handler); - - emitter.emitFiltered('data', 5); - expect(handler).toHaveBeenCalledWith(5); - }); - - it('should not emit when filter fails', () => { - const emitter = new FilteredEmitter(); - const handler = vi.fn(); - - emitter.addFilter('data', (d) => (d as number) > 0); - emitter.on('data', handler); - - emitter.emitFiltered('data', -5); - expect(handler).not.toHaveBeenCalled(); - }); - - it('should apply multiple filters', () => { - const emitter = new FilteredEmitter(); - const handler = vi.fn(); - - emitter.addFilter('data', (d) => (d as number) > 0); - emitter.addFilter('data', (d) => (d as number) < 100); - emitter.on('data', handler); - - emitter.emitFiltered('data', 50); - expect(handler).toHaveBeenCalled(); - - handler.mockClear(); - emitter.emitFiltered('data', 150); - expect(handler).not.toHaveBeenCalled(); - }); - - it('should clear filters', () => { - const emitter = new FilteredEmitter(); - const handler = vi.fn(); - - emitter.addFilter('data', () => false); - emitter.on('data', handler); - - emitter.emitFiltered('data', 'test'); - expect(handler).not.toHaveBeenCalled(); - - emitter.clearFilters('data'); - emitter.emitFiltered('data', 'test'); - expect(handler).toHaveBeenCalled(); - }); - }); - - describe('Event Replay Pattern', () => { - class ReplayEmitter extends EventEmitter { - private history: Map = new Map(); - private maxHistory: number; - - constructor(maxHistory: number = 10) { - super(); - this.maxHistory = maxHistory; - } - - emitWithHistory(event: string, data: unknown): boolean { - if (!this.history.has(event)) { - this.history.set(event, []); - } - const events = this.history.get(event)!; - events.push(data); - if (events.length > this.maxHistory) { - events.shift(); - } - return this.emit(event, data); - } - - replay(event: string, listener: (data: unknown) => void): void { - const events = this.history.get(event) || []; - events.forEach(listener); - } - - clearHistory(event?: string): void { - if (event) { - this.history.delete(event); - } else { - this.history.clear(); - } - } - - getHistory(event: string): unknown[] { - return [...(this.history.get(event) || [])]; - } - } - - it('should store event history', () => { - const emitter = new ReplayEmitter(); - emitter.emitWithHistory('data', 'a'); - emitter.emitWithHistory('data', 'b'); - - const history = emitter.getHistory('data'); - expect(history).toEqual(['a', 'b']); - }); - - it('should replay events to new listener', () => { - const emitter = new ReplayEmitter(); - emitter.emitWithHistory('data', 1); - emitter.emitWithHistory('data', 2); - emitter.emitWithHistory('data', 3); - - const received: number[] = []; - emitter.replay('data', (d) => received.push(d as number)); - expect(received).toEqual([1, 2, 3]); - }); - - it('should respect max history', () => { - const emitter = new ReplayEmitter(3); - for (let i = 0; i < 5; i++) { - emitter.emitWithHistory('data', i); - } - - const history = emitter.getHistory('data'); - expect(history).toEqual([2, 3, 4]); - }); - - it('should clear history', () => { - const emitter = new ReplayEmitter(); - emitter.emitWithHistory('data', 'a'); - emitter.clearHistory('data'); - expect(emitter.getHistory('data')).toEqual([]); - }); - }); - - describe('Event Transform Pattern', () => { - class TransformEmitter extends EventEmitter { - private transforms: Map unknown)[]> = new Map(); - - addTransform(event: string, transform: (data: unknown) => unknown): void { - if (!this.transforms.has(event)) { - this.transforms.set(event, []); - } - this.transforms.get(event)!.push(transform); - } - - emitTransformed(event: string, data: unknown): boolean { - const transforms = this.transforms.get(event) || []; - const transformed = transforms.reduce((d, t) => t(d), data); - return this.emit(event, transformed); - } - } - - it('should transform data before emission', () => { - const emitter = new TransformEmitter(); - const handler = vi.fn(); - - emitter.addTransform('data', (d) => (d as number) * 2); - emitter.on('data', handler); - - emitter.emitTransformed('data', 5); - expect(handler).toHaveBeenCalledWith(10); - }); - - it('should chain transforms', () => { - const emitter = new TransformEmitter(); - const handler = vi.fn(); - - emitter.addTransform('data', (d) => (d as number) + 1); - emitter.addTransform('data', (d) => (d as number) * 2); - emitter.on('data', handler); - - emitter.emitTransformed('data', 5); - // (5 + 1) * 2 = 12 - expect(handler).toHaveBeenCalledWith(12); - }); - - it('should handle string transforms', () => { - const emitter = new TransformEmitter(); - const handler = vi.fn(); - - emitter.addTransform('message', (d) => (d as string).toUpperCase()); - emitter.on('message', handler); - - emitter.emitTransformed('message', 'hello'); - expect(handler).toHaveBeenCalledWith('HELLO'); - }); - }); - - describe('Event Debouncing', () => { - class DebouncedEmitter extends EventEmitter { - private timers: Map> = new Map(); - private delays: Map = new Map(); - - setDebounce(event: string, delay: number): void { - this.delays.set(event, delay); - } - - emitDebounced(event: string, data: unknown): void { - const existingTimer = this.timers.get(event); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const delay = this.delays.get(event) || 0; - if (delay === 0) { - this.emit(event, data); - return; - } - - const timer = setTimeout(() => { - this.timers.delete(event); - this.emit(event, data); - }, delay); - - this.timers.set(event, timer); - } - - flush(event: string): void { - const timer = this.timers.get(event); - if (timer) { - clearTimeout(timer); - this.timers.delete(event); - } - } - - cancel(event: string): void { - const timer = this.timers.get(event); - if (timer) { - clearTimeout(timer); - this.timers.delete(event); - } - } - } - - it('should emit immediately with no delay', () => { - const emitter = new DebouncedEmitter(); - const handler = vi.fn(); - - emitter.on('data', handler); - emitter.emitDebounced('data', 'test'); - - expect(handler).toHaveBeenCalledWith('test'); - }); - - it('should cancel pending emissions', () => { - const emitter = new DebouncedEmitter(); - const handler = vi.fn(); - - emitter.setDebounce('data', 100); - emitter.on('data', handler); - emitter.emitDebounced('data', 'test'); - emitter.cancel('data'); - - // Wait a bit and verify nothing was called - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('Event Subscription Management', () => { - class ManagedEmitter extends EventEmitter { - private subscriptions: Map void>> = new Map(); - - subscribe(event: string, handler: (...args: unknown[]) => void): () => void { - if (!this.subscriptions.has(event)) { - this.subscriptions.set(event, new Set()); - } - this.subscriptions.get(event)!.add(handler); - this.on(event, handler); - - return () => { - this.subscriptions.get(event)?.delete(handler); - this.off(event, handler); - }; - } - - getSubscriberCount(event: string): number { - return this.subscriptions.get(event)?.size || 0; - } - - unsubscribeAll(event?: string): void { - if (event) { - const handlers = this.subscriptions.get(event); - if (handlers) { - handlers.forEach(h => this.off(event, h)); - this.subscriptions.delete(event); - } - } else { - this.subscriptions.forEach((handlers, evt) => { - handlers.forEach(h => this.off(evt, h)); - }); - this.subscriptions.clear(); - } - } - } - - it('should track subscriptions', () => { - const emitter = new ManagedEmitter(); - - emitter.subscribe('test', () => {}); - emitter.subscribe('test', () => {}); - - expect(emitter.getSubscriberCount('test')).toBe(2); - }); - - it('should return unsubscribe function', () => { - const emitter = new ManagedEmitter(); - const handler = vi.fn(); - - const unsubscribe = emitter.subscribe('test', handler); - emitter.emit('test'); - expect(handler).toHaveBeenCalledTimes(1); - - unsubscribe(); - emitter.emit('test'); - expect(handler).toHaveBeenCalledTimes(1); - }); - - it('should unsubscribe all handlers', () => { - const emitter = new ManagedEmitter(); - const handler1 = vi.fn(); - const handler2 = vi.fn(); - - emitter.subscribe('test', handler1); - emitter.subscribe('test', handler2); - - emitter.unsubscribeAll('test'); - emitter.emit('test'); - - expect(handler1).not.toHaveBeenCalled(); - expect(handler2).not.toHaveBeenCalled(); - expect(emitter.getSubscriberCount('test')).toBe(0); - }); - }); - - describe('Error Handling', () => { - it('should handle errors in listeners', () => { - const emitter = new EventEmitter(); - const handler1 = vi.fn(); - const handler2 = vi.fn(); - - emitter.on('test', () => { - throw new Error('Handler error'); - }); - emitter.on('test', handler2); - - // Without error handler, this would throw - emitter.on('error', handler1); - - try { - emitter.emit('test'); - } catch { - // Expected - } - - // handler2 won't be called if handler1 throws synchronously - expect(handler2).not.toHaveBeenCalled(); - }); - - it('should propagate errors with error event', () => { - const emitter = new EventEmitter(); - const errorHandler = vi.fn(); - - emitter.on('error', errorHandler); - emitter.emit('error', new Error('test error')); - - expect(errorHandler).toHaveBeenCalled(); - }); - }); - - describe('Max Listeners Warning', () => { - it('should track listener count', () => { - const emitter = new EventEmitter(); - emitter.setMaxListeners(5); - - for (let i = 0; i < 3; i++) { - emitter.on('test', () => {}); - } - - expect(emitter.listenerCount('test')).toBe(3); - }); - - it('should get max listeners', () => { - const emitter = new EventEmitter(); - emitter.setMaxListeners(20); - expect(emitter.getMaxListeners()).toBe(20); - }); - }); -}); - -describe('Event Bus Pattern', () => { - class EventBus { - private emitter = new EventEmitter(); - private static instance: EventBus | null = null; - - static getInstance(): EventBus { - if (!EventBus.instance) { - EventBus.instance = new EventBus(); - } - return EventBus.instance; - } - - static resetInstance(): void { - EventBus.instance = null; - } - - publish(event: string, data?: unknown): void { - this.emitter.emit(event, data); - } - - subscribe(event: string, handler: (data?: unknown) => void): () => void { - this.emitter.on(event, handler); - return () => this.emitter.off(event, handler); - } - - subscribeOnce(event: string, handler: (data?: unknown) => void): void { - this.emitter.once(event, handler); - } - } - - it('should create singleton instance', () => { - EventBus.resetInstance(); - const bus1 = EventBus.getInstance(); - const bus2 = EventBus.getInstance(); - expect(bus1).toBe(bus2); - }); - - it('should publish and subscribe', () => { - EventBus.resetInstance(); - const bus = EventBus.getInstance(); - const handler = vi.fn(); - - bus.subscribe('test', handler); - bus.publish('test', 'data'); - - expect(handler).toHaveBeenCalledWith('data'); - }); - - it('should unsubscribe', () => { - EventBus.resetInstance(); - const bus = EventBus.getInstance(); - const handler = vi.fn(); - - const unsubscribe = bus.subscribe('test', handler); - unsubscribe(); - bus.publish('test', 'data'); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('should handle once subscription', () => { - EventBus.resetInstance(); - const bus = EventBus.getInstance(); - const handler = vi.fn(); - - bus.subscribeOnce('test', handler); - bus.publish('test', 'first'); - bus.publish('test', 'second'); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith('first'); - }); -}); diff --git a/test/final-coverage.test.ts b/test/final-coverage.test.ts deleted file mode 100644 index b9e7f773..00000000 --- a/test/final-coverage.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -/** - * @fileoverview Final coverage tests to reach 1337 total tests - * - * Additional edge case tests to ensure comprehensive coverage. - */ - -import { describe, it, expect } from 'vitest'; - -describe('Final Coverage Tests', () => { - describe('Numeric Edge Cases', () => { - const safeParseInt = (value: unknown): number | null => { - if (typeof value === 'number' && Number.isInteger(value)) return value; - if (typeof value === 'string') { - const parsed = parseInt(value, 10); - if (!isNaN(parsed)) return parsed; - } - return null; - }; - - const clamp = (value: number, min: number, max: number): number => { - return Math.max(min, Math.min(max, value)); - }; - - const percentage = (value: number, total: number): number => { - if (total === 0) return 0; - return Math.round((value / total) * 100); - }; - - it('should safely parse integers', () => { - expect(safeParseInt(42)).toBe(42); - expect(safeParseInt('42')).toBe(42); - expect(safeParseInt('abc')).toBeNull(); - expect(safeParseInt(null)).toBeNull(); - expect(safeParseInt(3.14)).toBeNull(); - }); - - it('should clamp values', () => { - expect(clamp(5, 0, 10)).toBe(5); - expect(clamp(-5, 0, 10)).toBe(0); - expect(clamp(15, 0, 10)).toBe(10); - }); - - it('should calculate percentage', () => { - expect(percentage(25, 100)).toBe(25); - expect(percentage(1, 3)).toBe(33); - expect(percentage(0, 100)).toBe(0); - expect(percentage(100, 0)).toBe(0); - }); - }); - - describe('String Edge Cases', () => { - const capitalize = (str: string): string => { - if (str.length === 0) return str; - return str.charAt(0).toUpperCase() + str.slice(1); - }; - - const reverse = (str: string): string => { - return str.split('').reverse().join(''); - }; - - const countOccurrences = (str: string, char: string): number => { - return str.split(char).length - 1; - }; - - const wrap = (str: string, prefix: string, suffix: string): string => { - return `${prefix}${str}${suffix}`; - }; - - it('should capitalize strings', () => { - expect(capitalize('hello')).toBe('Hello'); - expect(capitalize('')).toBe(''); - expect(capitalize('H')).toBe('H'); - }); - - it('should reverse strings', () => { - expect(reverse('hello')).toBe('olleh'); - expect(reverse('')).toBe(''); - expect(reverse('a')).toBe('a'); - }); - - it('should count character occurrences', () => { - expect(countOccurrences('hello', 'l')).toBe(2); - expect(countOccurrences('hello', 'x')).toBe(0); - expect(countOccurrences('', 'a')).toBe(0); - }); - - it('should wrap strings', () => { - expect(wrap('text', '[', ']')).toBe('[text]'); - expect(wrap('', '<', '>')).toBe('<>'); - }); - }); - - describe('Array Edge Cases', () => { - const first = (arr: T[]): T | undefined => arr[0]; - const last = (arr: T[]): T | undefined => arr[arr.length - 1]; - const compact = (arr: (T | null | undefined)[]): T[] => { - return arr.filter((x): x is T => x !== null && x !== undefined); - }; - const chunk = (arr: T[], size: number): T[][] => { - const chunks: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; - }; - - it('should get first element', () => { - expect(first([1, 2, 3])).toBe(1); - expect(first([])).toBeUndefined(); - }); - - it('should get last element', () => { - expect(last([1, 2, 3])).toBe(3); - expect(last([])).toBeUndefined(); - }); - - it('should compact arrays', () => { - expect(compact([1, null, 2, undefined, 3])).toEqual([1, 2, 3]); - expect(compact([])).toEqual([]); - }); - - it('should chunk arrays', () => { - expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); - expect(chunk([], 2)).toEqual([]); - }); - }); - - describe('Object Edge Cases', () => { - const pick = (obj: T, keys: K[]): Pick => { - const result = {} as Pick; - for (const key of keys) { - if (key in obj) { - result[key] = obj[key]; - } - } - return result; - }; - - const omit = (obj: T, keys: K[]): Omit => { - const result = { ...obj }; - for (const key of keys) { - delete result[key]; - } - return result as Omit; - }; - - const merge = (a: T, b: U): T & U => { - return { ...a, ...b }; - }; - - it('should pick keys', () => { - const obj = { a: 1, b: 2, c: 3 }; - expect(pick(obj, ['a', 'c'])).toEqual({ a: 1, c: 3 }); - }); - - it('should omit keys', () => { - const obj = { a: 1, b: 2, c: 3 }; - expect(omit(obj, ['b'])).toEqual({ a: 1, c: 3 }); - }); - - it('should merge objects', () => { - expect(merge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); - expect(merge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); - }); - }); - - describe('Boolean Logic', () => { - const xor = (a: boolean, b: boolean): boolean => { - return (a || b) && !(a && b); - }; - - const nand = (a: boolean, b: boolean): boolean => { - return !(a && b); - }; - - const implies = (a: boolean, b: boolean): boolean => { - return !a || b; - }; - - it('should compute XOR', () => { - expect(xor(true, false)).toBe(true); - expect(xor(false, true)).toBe(true); - expect(xor(true, true)).toBe(false); - expect(xor(false, false)).toBe(false); - }); - - it('should compute NAND', () => { - expect(nand(true, true)).toBe(false); - expect(nand(true, false)).toBe(true); - expect(nand(false, true)).toBe(true); - expect(nand(false, false)).toBe(true); - }); - - it('should compute implication', () => { - expect(implies(true, true)).toBe(true); - expect(implies(true, false)).toBe(false); - expect(implies(false, true)).toBe(true); - expect(implies(false, false)).toBe(true); - }); - }); - - describe('1337 Target Tests', () => { - it('should reach test count 1330', () => { - expect(1330).toBe(1330); - }); - - it('should reach test count 1331', () => { - expect(1331).toBe(1331); - }); - - it('should reach test count 1332', () => { - expect(1332).toBe(1332); - }); - - it('should reach test count 1333', () => { - expect(1333).toBe(1333); - }); - - it('should reach test count 1334', () => { - expect(1334).toBe(1334); - }); - - it('should reach test count 1335', () => { - expect(1335).toBe(1335); - }); - - it('should reach test count 1336', () => { - expect(1336).toBe(1336); - }); - - it('should reach test count 1337 - LEET', () => { - expect(1337).toBe(1337); - }); - - it('should confirm LEET status achieved', () => { - const isLeet = (n: number) => n === 1337; - expect(isLeet(1337)).toBe(true); - }); - - it('should celebrate 1337 with style', () => { - const leet = 'LEET'; - expect(leet.length + 1333).toBe(1337); - }); - }); - - describe('Random Utilities', () => { - const randomInt = (min: number, max: number): number => { - return Math.floor(Math.random() * (max - min + 1)) + min; - }; - - const randomChoice = (arr: T[]): T | undefined => { - if (arr.length === 0) return undefined; - return arr[Math.floor(Math.random() * arr.length)]; - }; - - const shuffle = (arr: T[]): T[] => { - const result = [...arr]; - for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [result[i], result[j]] = [result[j], result[i]]; - } - return result; - }; - - it('should generate random int in range', () => { - for (let i = 0; i < 10; i++) { - const n = randomInt(1, 10); - expect(n).toBeGreaterThanOrEqual(1); - expect(n).toBeLessThanOrEqual(10); - } - }); - - it('should pick random choice', () => { - expect(randomChoice([])).toBeUndefined(); - expect(randomChoice([42])).toBe(42); - }); - - it('should shuffle arrays', () => { - const original = [1, 2, 3, 4, 5]; - const shuffled = shuffle(original); - expect(shuffled).toHaveLength(5); - expect(shuffled.sort()).toEqual([1, 2, 3, 4, 5]); - }); - }); - - describe('Date Utilities', () => { - const formatDate = (date: Date): string => { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; - }; - - const addDays = (date: Date, days: number): Date => { - const result = new Date(date); - result.setDate(result.getDate() + days); - return result; - }; - - const isWeekend = (date: Date): boolean => { - const day = date.getDay(); - return day === 0 || day === 6; - }; - - it('should format dates', () => { - const date = new Date('2024-01-15'); - expect(formatDate(date)).toBe('2024-01-15'); - }); - - it('should add days', () => { - const date = new Date('2024-01-15'); - const future = addDays(date, 5); - expect(future.getDate()).toBe(20); - }); - - it('should detect weekends', () => { - // Saturday - expect(isWeekend(new Date('2024-01-13'))).toBe(true); - // Sunday - expect(isWeekend(new Date('2024-01-14'))).toBe(true); - // Monday - expect(isWeekend(new Date('2024-01-15'))).toBe(false); - }); - }); - - describe('Error Handling', () => { - const tryCatch = (fn: () => T): [T, null] | [null, Error] => { - try { - return [fn(), null]; - } catch (e) { - return [null, e as Error]; - } - }; - - const attempt = (fn: () => T, defaultValue: T): T => { - try { - return fn(); - } catch { - return defaultValue; - } - }; - - it('should catch errors', () => { - const [result, error] = tryCatch(() => { - throw new Error('oops'); - }); - expect(result).toBeNull(); - expect(error?.message).toBe('oops'); - }); - - it('should return result on success', () => { - const [result, error] = tryCatch(() => 42); - expect(result).toBe(42); - expect(error).toBeNull(); - }); - - it('should use default on error', () => { - const result = attempt(() => { - throw new Error('oops'); - }, 'default'); - expect(result).toBe('default'); - }); - - it('should return value on success', () => { - const result = attempt(() => 'success', 'default'); - expect(result).toBe('success'); - }); - }); - - describe('Path Manipulation', () => { - const join = (...parts: string[]): string => { - return parts.join('/').replace(/\/+/g, '/'); - }; - - const normalize = (path: string): string => { - const parts = path.split('/').filter(p => p !== ''); - const result: string[] = []; - for (const part of parts) { - if (part === '..') { - result.pop(); - } else if (part !== '.') { - result.push(part); - } - } - return (path.startsWith('/') ? '/' : '') + result.join('/'); - }; - - const isAbsolute = (path: string): boolean => { - return path.startsWith('/'); - }; - - it('should join paths', () => { - expect(join('a', 'b', 'c')).toBe('a/b/c'); - expect(join('/a/', '/b/')).toBe('/a/b/'); - }); - - it('should normalize paths', () => { - expect(normalize('/a/b/../c')).toBe('/a/c'); - expect(normalize('/a/./b/./c')).toBe('/a/b/c'); - expect(normalize('a/b/../c')).toBe('a/c'); - }); - - it('should check absolute paths', () => { - expect(isAbsolute('/home/user')).toBe(true); - expect(isAbsolute('relative/path')).toBe(false); - }); - }); -}); diff --git a/test/mobile-settings-test.ts b/test/mobile-settings-test.ts deleted file mode 100644 index 3bca68e4..00000000 --- a/test/mobile-settings-test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Mobile settings test - verifies: - * 1. Mobile uses separate storage key - * 2. Mobile defaults are applied correctly - * 3. Notifications are disabled by default on mobile - */ -import { chromium } from 'playwright'; - -const MOBILE_VIEWPORT = { - width: 402, - height: 874, - deviceScaleFactor: 2, - isMobile: true, - hasTouch: true, -}; - -const DESKTOP_VIEWPORT = { - width: 1280, - height: 800, - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, -}; - -async function testMobileSettings() { - console.log('=== Mobile Settings Test ===\n'); - - const browser = await chromium.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'] - }); - - try { - // Test 1: Mobile device - console.log('1. Testing MOBILE view...'); - const mobileContext = await browser.newContext({ - viewport: { width: MOBILE_VIEWPORT.width, height: MOBILE_VIEWPORT.height }, - deviceScaleFactor: MOBILE_VIEWPORT.deviceScaleFactor, - isMobile: MOBILE_VIEWPORT.isMobile, - hasTouch: MOBILE_VIEWPORT.hasTouch, - }); - const mobilePage = await mobileContext.newPage(); - mobilePage.setDefaultTimeout(15000); - - await mobilePage.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' }); - // Clear localStorage to test fresh defaults - await mobilePage.evaluate(() => localStorage.clear()); - // Reload to apply fresh defaults - await mobilePage.reload({ waitUntil: 'domcontentloaded' }); - await mobilePage.waitForSelector('.toolbar', { timeout: 10000 }); - await mobilePage.waitForTimeout(500); - - // Check device type detection - const mobileDeviceType = await mobilePage.evaluate(() => { - // @ts-ignore - return window.MobileDetection?.getDeviceType() || 'unknown'; - }); - console.log(` Device type detected: ${mobileDeviceType}`); - - // Check storage key used - const mobileStorageKeys = await mobilePage.evaluate(() => { - const keys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.includes('claudeman')) { - keys.push(key); - } - } - return keys; - }); - console.log(` LocalStorage keys: ${mobileStorageKeys.join(', ') || 'none'}`); - - // Check default settings (before server merge) - const mobileDefaults = await mobilePage.evaluate(() => { - // @ts-ignore - return window.app?.getDefaultSettings() || {}; - }); - console.log(` Default settings (mobile-specific):`); - console.log(` showSystemStats: ${mobileDefaults.showSystemStats}`); - console.log(` showMonitor: ${mobileDefaults.showMonitor}`); - console.log(` subagentTrackingEnabled: ${mobileDefaults.subagentTrackingEnabled}`); - - // Check loaded settings (after server merge) - const mobileSettings = await mobilePage.evaluate(() => { - // @ts-ignore - return window.app?.loadAppSettingsFromStorage() || {}; - }); - console.log(` Settings loaded (after server merge):`); - console.log(` showSystemStats: ${mobileSettings.showSystemStats}`); - console.log(` showMonitor: ${mobileSettings.showMonitor}`); - console.log(` subagentTrackingEnabled: ${mobileSettings.subagentTrackingEnabled}`); - - // Check notification settings - const mobileNotifPrefs = await mobilePage.evaluate(() => { - // @ts-ignore - return window.app?.notificationManager?.preferences || {}; - }); - console.log(` Notification prefs:`); - console.log(` enabled: ${mobileNotifPrefs.enabled}`); - console.log(` browserNotifications: ${mobileNotifPrefs.browserNotifications}`); - - // Check header visibility - const headerStatsVisible = await mobilePage.evaluate(() => { - const el = document.getElementById('headerSystemStats'); - return el ? window.getComputedStyle(el).display !== 'none' : 'not found'; - }); - console.log(` Header system stats visible: ${headerStatsVisible}`); - - // Check active tab is first - const tabs = await mobilePage.$$('.session-tab'); - const firstTabActive = tabs.length > 0 ? await tabs[0].evaluate(el => el.classList.contains('active')) : 'no tabs'; - console.log(` Active tab first: ${firstTabActive} (${tabs.length} tabs)`); - - // Take screenshot - await mobilePage.screenshot({ path: 'test/mobile-settings-test.png' }); - console.log(' Screenshot: test/mobile-settings-test.png\n'); - - await mobileContext.close(); - - // Test 2: Desktop device for comparison - console.log('2. Testing DESKTOP view...'); - const desktopContext = await browser.newContext({ - viewport: { width: DESKTOP_VIEWPORT.width, height: DESKTOP_VIEWPORT.height }, - deviceScaleFactor: DESKTOP_VIEWPORT.deviceScaleFactor, - isMobile: DESKTOP_VIEWPORT.isMobile, - hasTouch: DESKTOP_VIEWPORT.hasTouch, - }); - const desktopPage = await desktopContext.newPage(); - desktopPage.setDefaultTimeout(15000); - - await desktopPage.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' }); - // Clear localStorage to test fresh defaults - await desktopPage.evaluate(() => localStorage.clear()); - // Reload to apply fresh defaults - await desktopPage.reload({ waitUntil: 'domcontentloaded' }); - await desktopPage.waitForSelector('.toolbar', { timeout: 10000 }); - await desktopPage.waitForTimeout(500); - - const desktopDeviceType = await desktopPage.evaluate(() => { - // @ts-ignore - return window.MobileDetection?.getDeviceType() || 'unknown'; - }); - console.log(` Device type detected: ${desktopDeviceType}`); - - // Check default settings - const desktopDefaults = await desktopPage.evaluate(() => { - // @ts-ignore - return window.app?.getDefaultSettings() || {}; - }); - console.log(` Default settings (desktop = empty, uses ?? fallbacks):`); - console.log(` isEmpty: ${Object.keys(desktopDefaults).length === 0}`); - - // Check loaded settings (after server merge) - const desktopSettings = await desktopPage.evaluate(() => { - // @ts-ignore - return window.app?.loadAppSettingsFromStorage() || {}; - }); - console.log(` Settings loaded (after server merge):`); - console.log(` showSystemStats: ${desktopSettings.showSystemStats ?? 'undefined (default: true)'}`); - console.log(` showMonitor: ${desktopSettings.showMonitor ?? 'undefined (default: true)'}`); - - const desktopNotifPrefs = await desktopPage.evaluate(() => { - // @ts-ignore - return window.app?.notificationManager?.preferences || {}; - }); - console.log(` Notification prefs:`); - console.log(` enabled: ${desktopNotifPrefs.enabled}`); - console.log(` browserNotifications: ${desktopNotifPrefs.browserNotifications}`); - - await desktopPage.screenshot({ path: 'test/desktop-settings-test.png' }); - console.log(' Screenshot: test/desktop-settings-test.png\n'); - - await desktopContext.close(); - - console.log('=== Test Complete ==='); - - } finally { - await browser.close(); - } -} - -testMobileSettings().catch(console.error); diff --git a/test/timing-utilities.test.ts b/test/timing-utilities.test.ts deleted file mode 100644 index bd6a818d..00000000 --- a/test/timing-utilities.test.ts +++ /dev/null @@ -1,802 +0,0 @@ -/** - * @fileoverview Tests for timing and scheduling utilities - * - * Tests debouncing, throttling, retry logic, and other - * time-based patterns used in the application. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -describe('Timing Utilities', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('Debounce', () => { - const debounce = void>( - fn: T, - delay: number - ): ((...args: Parameters) => void) => { - let timeoutId: ReturnType | null = null; - return (...args: Parameters) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - fn(...args); - timeoutId = null; - }, delay); - }; - }; - - it('should delay execution', () => { - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced(); - expect(fn).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should reset timer on subsequent calls', () => { - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced(); - vi.advanceTimersByTime(50); - debounced(); - vi.advanceTimersByTime(50); - - expect(fn).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(50); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should pass arguments', () => { - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced('arg1', 'arg2'); - vi.advanceTimersByTime(100); - - expect(fn).toHaveBeenCalledWith('arg1', 'arg2'); - }); - - it('should use latest arguments', () => { - const fn = vi.fn(); - const debounced = debounce(fn, 100); - - debounced('first'); - debounced('second'); - debounced('third'); - - vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledWith('third'); - expect(fn).toHaveBeenCalledTimes(1); - }); - }); - - describe('Throttle', () => { - const throttle = void>( - fn: T, - limit: number - ): ((...args: Parameters) => void) => { - let inThrottle = false; - let lastArgs: Parameters | null = null; - - return (...args: Parameters) => { - if (!inThrottle) { - fn(...args); - inThrottle = true; - setTimeout(() => { - inThrottle = false; - if (lastArgs) { - fn(...lastArgs); - lastArgs = null; - } - }, limit); - } else { - lastArgs = args; - } - }; - }; - - it('should execute immediately first time', () => { - const fn = vi.fn(); - const throttled = throttle(fn, 100); - - throttled(); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should throttle subsequent calls', () => { - const fn = vi.fn(); - const throttled = throttle(fn, 100); - - throttled(); - throttled(); - throttled(); - - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should execute trailing call after limit', () => { - const fn = vi.fn(); - const throttled = throttle(fn, 100); - - throttled('first'); - throttled('second'); - - vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledTimes(2); - expect(fn).toHaveBeenLastCalledWith('second'); - }); - - it('should allow execution after limit expires', () => { - const fn = vi.fn(); - const throttled = throttle(fn, 100); - - throttled(); - vi.advanceTimersByTime(100); - throttled(); - - expect(fn).toHaveBeenCalledTimes(2); - }); - }); - - describe('Retry with Backoff', () => { - const retryWithBackoff = async ( - fn: () => Promise, - maxRetries: number, - baseDelay: number - ): Promise => { - let lastError: Error | null = null; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error as Error; - if (attempt < maxRetries) { - const delay = baseDelay * Math.pow(2, attempt); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - throw lastError; - }; - - it('should succeed on first try', async () => { - const fn = vi.fn().mockResolvedValue('success'); - const result = await retryWithBackoff(fn, 3, 100); - expect(result).toBe('success'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should retry on failure', async () => { - const fn = vi.fn() - .mockRejectedValueOnce(new Error('fail')) - .mockResolvedValue('success'); - - const promise = retryWithBackoff(fn, 3, 100); - await vi.advanceTimersByTimeAsync(100); - const result = await promise; - - expect(result).toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('should throw after max retries', async () => { - const fn = vi.fn().mockRejectedValue(new Error('always fails')); - - const promise = retryWithBackoff(fn, 2, 100); - const expectation = expect(promise).rejects.toThrow('always fails'); - - await vi.advanceTimersByTimeAsync(100); // First retry - await vi.advanceTimersByTimeAsync(200); // Second retry - - await expectation; - expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries - }); - }); - - describe('Timeout Wrapper', () => { - const withTimeout = ( - promise: Promise, - timeout: number, - message: string = 'Operation timed out' - ): Promise => { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(message)); - }, timeout); - - promise - .then((result) => { - clearTimeout(timeoutId); - resolve(result); - }) - .catch((error) => { - clearTimeout(timeoutId); - reject(error); - }); - }); - }; - - it('should resolve before timeout', async () => { - const promise = new Promise(resolve => - setTimeout(() => resolve('done'), 50) - ); - - const wrapped = withTimeout(promise, 100); - await vi.advanceTimersByTimeAsync(50); - - await expect(wrapped).resolves.toBe('done'); - }); - - it('should reject on timeout', async () => { - const promise = new Promise(resolve => - setTimeout(() => resolve('done'), 200) - ); - - const wrapped = withTimeout(promise, 100); - const expectation = expect(wrapped).rejects.toThrow('Operation timed out'); - await vi.advanceTimersByTimeAsync(100); - await expectation; - }); - - it('should use custom timeout message', async () => { - const promise = new Promise(resolve => - setTimeout(() => resolve('done'), 200) - ); - - const wrapped = withTimeout(promise, 100, 'Custom message'); - const expectation = expect(wrapped).rejects.toThrow('Custom message'); - await vi.advanceTimersByTimeAsync(100); - await expectation; - }); - }); - - describe('Rate Limiter', () => { - class RateLimiter { - private tokens: number; - private maxTokens: number; - private refillRate: number; - private lastRefill: number; - - constructor(maxTokens: number, refillRate: number) { - this.maxTokens = maxTokens; - this.tokens = maxTokens; - this.refillRate = refillRate; - this.lastRefill = Date.now(); - } - - private refill(): void { - const now = Date.now(); - const elapsed = now - this.lastRefill; - const newTokens = Math.floor(elapsed / 1000) * this.refillRate; - this.tokens = Math.min(this.maxTokens, this.tokens + newTokens); - this.lastRefill = now; - } - - tryAcquire(): boolean { - this.refill(); - if (this.tokens > 0) { - this.tokens--; - return true; - } - return false; - } - - getTokens(): number { - this.refill(); - return this.tokens; - } - } - - it('should allow requests within limit', () => { - const limiter = new RateLimiter(3, 1); - expect(limiter.tryAcquire()).toBe(true); - expect(limiter.tryAcquire()).toBe(true); - expect(limiter.tryAcquire()).toBe(true); - }); - - it('should block requests over limit', () => { - const limiter = new RateLimiter(2, 1); - limiter.tryAcquire(); - limiter.tryAcquire(); - expect(limiter.tryAcquire()).toBe(false); - }); - - it('should refill tokens over time', () => { - const limiter = new RateLimiter(2, 1); - limiter.tryAcquire(); - limiter.tryAcquire(); - expect(limiter.getTokens()).toBe(0); - - vi.advanceTimersByTime(1000); - expect(limiter.getTokens()).toBe(1); - }); - - it('should not exceed max tokens', () => { - const limiter = new RateLimiter(2, 10); - vi.advanceTimersByTime(10000); - expect(limiter.getTokens()).toBe(2); - }); - }); - - describe('Interval Manager', () => { - class IntervalManager { - private intervals: Map> = new Map(); - - start(id: string, callback: () => void, interval: number): void { - this.stop(id); - this.intervals.set(id, setInterval(callback, interval)); - } - - stop(id: string): void { - const existing = this.intervals.get(id); - if (existing) { - clearInterval(existing); - this.intervals.delete(id); - } - } - - stopAll(): void { - for (const [id] of this.intervals) { - this.stop(id); - } - } - - isRunning(id: string): boolean { - return this.intervals.has(id); - } - - getRunningCount(): number { - return this.intervals.size; - } - } - - it('should start interval', () => { - const manager = new IntervalManager(); - const callback = vi.fn(); - - manager.start('test', callback, 100); - expect(manager.isRunning('test')).toBe(true); - - vi.advanceTimersByTime(350); - expect(callback).toHaveBeenCalledTimes(3); - }); - - it('should stop interval', () => { - const manager = new IntervalManager(); - const callback = vi.fn(); - - manager.start('test', callback, 100); - manager.stop('test'); - - vi.advanceTimersByTime(500); - expect(callback).not.toHaveBeenCalled(); - }); - - it('should stop all intervals', () => { - const manager = new IntervalManager(); - - manager.start('a', vi.fn(), 100); - manager.start('b', vi.fn(), 100); - - expect(manager.getRunningCount()).toBe(2); - manager.stopAll(); - expect(manager.getRunningCount()).toBe(0); - }); - - it('should replace existing interval', () => { - const manager = new IntervalManager(); - const callback1 = vi.fn(); - const callback2 = vi.fn(); - - manager.start('test', callback1, 100); - manager.start('test', callback2, 100); - - vi.advanceTimersByTime(100); - expect(callback1).not.toHaveBeenCalled(); - expect(callback2).toHaveBeenCalled(); - }); - }); - - describe('Scheduled Task', () => { - class ScheduledTask { - private timeoutId: ReturnType | null = null; - private callback: () => void; - private scheduledTime: number | null = null; - - constructor(callback: () => void) { - this.callback = callback; - } - - scheduleAt(timestamp: number): void { - this.cancel(); - const delay = Math.max(0, timestamp - Date.now()); - this.scheduledTime = timestamp; - this.timeoutId = setTimeout(() => { - this.timeoutId = null; - this.scheduledTime = null; - this.callback(); - }, delay); - } - - scheduleIn(delay: number): void { - this.scheduleAt(Date.now() + delay); - } - - cancel(): void { - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - this.scheduledTime = null; - } - } - - isScheduled(): boolean { - return this.timeoutId !== null; - } - - getScheduledTime(): number | null { - return this.scheduledTime; - } - } - - it('should schedule task', () => { - const callback = vi.fn(); - const task = new ScheduledTask(callback); - - task.scheduleIn(100); - expect(task.isScheduled()).toBe(true); - - vi.advanceTimersByTime(100); - expect(callback).toHaveBeenCalled(); - expect(task.isScheduled()).toBe(false); - }); - - it('should cancel scheduled task', () => { - const callback = vi.fn(); - const task = new ScheduledTask(callback); - - task.scheduleIn(100); - task.cancel(); - - vi.advanceTimersByTime(100); - expect(callback).not.toHaveBeenCalled(); - expect(task.isScheduled()).toBe(false); - }); - - it('should reschedule task', () => { - const callback = vi.fn(); - const task = new ScheduledTask(callback); - - task.scheduleIn(100); - vi.advanceTimersByTime(50); - task.scheduleIn(100); // Reschedule - - vi.advanceTimersByTime(50); - expect(callback).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(50); - expect(callback).toHaveBeenCalled(); - }); - - it('should schedule at specific time', () => { - const callback = vi.fn(); - const task = new ScheduledTask(callback); - const now = Date.now(); - - task.scheduleAt(now + 200); - expect(task.getScheduledTime()).toBe(now + 200); - - vi.advanceTimersByTime(200); - expect(callback).toHaveBeenCalled(); - }); - }); - - describe('Cooldown', () => { - class Cooldown { - private cooldownUntil: number = 0; - - constructor(private duration: number) {} - - trigger(): void { - this.cooldownUntil = Date.now() + this.duration; - } - - isOnCooldown(): boolean { - return Date.now() < this.cooldownUntil; - } - - getRemainingTime(): number { - return Math.max(0, this.cooldownUntil - Date.now()); - } - - reset(): void { - this.cooldownUntil = 0; - } - - tryTrigger(): boolean { - if (this.isOnCooldown()) return false; - this.trigger(); - return true; - } - } - - it('should start on cooldown after trigger', () => { - const cooldown = new Cooldown(100); - cooldown.trigger(); - expect(cooldown.isOnCooldown()).toBe(true); - }); - - it('should end cooldown after duration', () => { - const cooldown = new Cooldown(100); - cooldown.trigger(); - - vi.advanceTimersByTime(100); - expect(cooldown.isOnCooldown()).toBe(false); - }); - - it('should report remaining time', () => { - const cooldown = new Cooldown(100); - cooldown.trigger(); - - vi.advanceTimersByTime(30); - expect(cooldown.getRemainingTime()).toBe(70); - }); - - it('should reset cooldown', () => { - const cooldown = new Cooldown(100); - cooldown.trigger(); - cooldown.reset(); - expect(cooldown.isOnCooldown()).toBe(false); - }); - - it('should prevent trigger during cooldown', () => { - const cooldown = new Cooldown(100); - expect(cooldown.tryTrigger()).toBe(true); - expect(cooldown.tryTrigger()).toBe(false); - - vi.advanceTimersByTime(100); - expect(cooldown.tryTrigger()).toBe(true); - }); - }); - - describe('Deadline', () => { - class Deadline { - private deadline: number; - - constructor(durationMs: number) { - this.deadline = Date.now() + durationMs; - } - - isExpired(): boolean { - return Date.now() >= this.deadline; - } - - getRemainingTime(): number { - return Math.max(0, this.deadline - Date.now()); - } - - extend(additionalMs: number): void { - this.deadline += additionalMs; - } - - static fromTimestamp(timestamp: number): Deadline { - const d = new Deadline(0); - d.deadline = timestamp; - return d; - } - } - - it('should not be expired initially', () => { - const deadline = new Deadline(100); - expect(deadline.isExpired()).toBe(false); - }); - - it('should expire after duration', () => { - const deadline = new Deadline(100); - vi.advanceTimersByTime(100); - expect(deadline.isExpired()).toBe(true); - }); - - it('should report remaining time', () => { - const deadline = new Deadline(100); - vi.advanceTimersByTime(30); - expect(deadline.getRemainingTime()).toBe(70); - }); - - it('should extend deadline', () => { - const deadline = new Deadline(100); - vi.advanceTimersByTime(80); - deadline.extend(50); - vi.advanceTimersByTime(30); - expect(deadline.isExpired()).toBe(false); - vi.advanceTimersByTime(40); - expect(deadline.isExpired()).toBe(true); - }); - }); - - describe('Polling', () => { - class Poller { - private intervalId: ReturnType | null = null; - private fn: () => Promise; - private interval: number; - private onResult: (result: T) => void; - private onError: (error: Error) => void; - - constructor(options: { - fn: () => Promise; - interval: number; - onResult: (result: T) => void; - onError?: (error: Error) => void; - }) { - this.fn = options.fn; - this.interval = options.interval; - this.onResult = options.onResult; - this.onError = options.onError || (() => {}); - } - - start(): void { - if (this.intervalId) return; - this.poll(); - this.intervalId = setInterval(() => this.poll(), this.interval); - } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - isRunning(): boolean { - return this.intervalId !== null; - } - - private async poll(): Promise { - try { - const result = await this.fn(); - this.onResult(result); - } catch (error) { - this.onError(error as Error); - } - } - } - - it('should poll immediately on start', async () => { - const fn = vi.fn().mockResolvedValue('result'); - const onResult = vi.fn(); - - const poller = new Poller({ fn, interval: 100, onResult }); - poller.start(); - - await vi.advanceTimersByTimeAsync(0); - expect(fn).toHaveBeenCalledTimes(1); - expect(onResult).toHaveBeenCalledWith('result'); - }); - - it('should poll at intervals', async () => { - const fn = vi.fn().mockResolvedValue('result'); - const onResult = vi.fn(); - - const poller = new Poller({ fn, interval: 100, onResult }); - poller.start(); - - await vi.advanceTimersByTimeAsync(250); - expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 intervals - }); - - it('should stop polling', async () => { - const fn = vi.fn().mockResolvedValue('result'); - const poller = new Poller({ fn, interval: 100, onResult: vi.fn() }); - - poller.start(); - poller.stop(); - - await vi.advanceTimersByTimeAsync(500); - expect(fn).toHaveBeenCalledTimes(1); // Only initial call - }); - - it('should handle errors', async () => { - const fn = vi.fn().mockRejectedValue(new Error('poll error')); - const onError = vi.fn(); - - const poller = new Poller({ fn, interval: 100, onResult: vi.fn(), onError }); - poller.start(); - - await vi.advanceTimersByTimeAsync(0); - expect(onError).toHaveBeenCalled(); - }); - }); - - describe('Duration Parsing', () => { - const parseDuration = (str: string): number => { - const match = str.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i); - if (!match) return NaN; - - const value = parseFloat(match[1]); - const unit = (match[2] || 'ms').toLowerCase(); - - const multipliers: Record = { - ms: 1, - s: 1000, - m: 60 * 1000, - h: 60 * 60 * 1000, - d: 24 * 60 * 60 * 1000, - }; - - return value * (multipliers[unit] || 1); - }; - - it('should parse milliseconds', () => { - expect(parseDuration('100')).toBe(100); - expect(parseDuration('100ms')).toBe(100); - }); - - it('should parse seconds', () => { - expect(parseDuration('5s')).toBe(5000); - expect(parseDuration('1.5s')).toBe(1500); - }); - - it('should parse minutes', () => { - expect(parseDuration('2m')).toBe(120000); - }); - - it('should parse hours', () => { - expect(parseDuration('1h')).toBe(3600000); - }); - - it('should parse days', () => { - expect(parseDuration('1d')).toBe(86400000); - }); - - it('should handle invalid input', () => { - expect(parseDuration('invalid')).toBeNaN(); - expect(parseDuration('')).toBeNaN(); - }); - }); - - describe('Time Formatting', () => { - const formatRelativeTime = (timestamp: number): string => { - const now = Date.now(); - const diff = now - timestamp; - - if (diff < 1000) return 'just now'; - if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - return `${Math.floor(diff / 86400000)}d ago`; - }; - - it('should format just now', () => { - expect(formatRelativeTime(Date.now())).toBe('just now'); - }); - - it('should format seconds ago', () => { - expect(formatRelativeTime(Date.now() - 30000)).toBe('30s ago'); - }); - - it('should format minutes ago', () => { - expect(formatRelativeTime(Date.now() - 300000)).toBe('5m ago'); - }); - - it('should format hours ago', () => { - expect(formatRelativeTime(Date.now() - 7200000)).toBe('2h ago'); - }); - - it('should format days ago', () => { - expect(formatRelativeTime(Date.now() - 172800000)).toBe('2d ago'); - }); - }); -});