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.
+
+
+
+
+
+
+
+
+
+### 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');
- });
- });
-});