chore: bump version to 0.1478

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-11 17:29:53 +01:00
co-authored by Claude Opus 4.6
parent 8f0bbf33d6
commit c2ab0c6a6f
15 changed files with 79 additions and 6305 deletions
+4 -3
View File
@@ -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 |
+74 -49
View File
@@ -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.
<p align="center">
<img src="docs/screenshots/mobile-main.png" alt="Mobile Dashboard" width="280">
&nbsp;&nbsp;
<img src="docs/screenshots/mobile-with-keyboard.png" alt="Mobile Keyboard" width="280">
&nbsp;&nbsp;
<img src="docs/screenshots/mobile-toolbar-above-keyboard.png" alt="Mobile Toolbar" width="280">
</p>
### 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://<your-ip>: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`
---
+1 -1
View File
@@ -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",
-759
View File
@@ -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<number> => 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 <T, R>(
items: T[],
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> => {
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 <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
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 <T, R>(
items: T[],
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> => {
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 <T>(
items: T[],
predicate: (item: T) => Promise<boolean>
): Promise<T[]> => {
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 <T>(
items: T[],
predicate: (item: T) => Promise<boolean>
): Promise<T | undefined> => {
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 <T>(
items: T[],
predicate: (item: T) => Promise<boolean>
): Promise<boolean> => {
for (const item of items) {
if (await predicate(item)) return true;
}
return false;
};
const asyncEvery = async <T>(
items: T[],
predicate: (item: T) => Promise<boolean>
): Promise<boolean> => {
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 <T, R>(
items: T[],
fn: (acc: R, item: T, index: number) => Promise<R>,
initial: R
): Promise<R> => {
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<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
}
const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
it('should resolve externally', async () => {
const deferred = createDeferred<number>();
deferred.resolve(42);
await expect(deferred.promise).resolves.toBe(42);
});
it('should reject externally', async () => {
const deferred = createDeferred<number>();
deferred.reject(new Error('fail'));
await expect(deferred.promise).rejects.toThrow('fail');
});
});
describe('Lazy Promise', () => {
const lazyPromise = <T>(fn: () => Promise<T>): (() => Promise<T>) => {
let cached: Promise<T> | 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<void> {
if (!this.locked) {
this.locked = true;
return;
}
return new Promise<void>((resolve) => {
this.waitQueue.push(resolve);
});
}
release(): void {
if (this.waitQueue.length > 0) {
const next = this.waitQueue.shift()!;
next();
} else {
this.locked = false;
}
}
async withLock<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
if (this.count > 0) {
this.count--;
return;
}
return new Promise<void>((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<T> {
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<T> {
if (this.items.length > 0) {
return this.items.shift()!;
}
return new Promise<T>((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<number>();
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<number>();
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 <T, R>(
items: T[],
concurrency: number,
iteratee: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = new Array(items.length);
let currentIndex = 0;
const worker = async (): Promise<void> => {
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 <T>(
fn: () => Promise<T>,
predicate: (result: T) => boolean,
maxAttempts: number,
delay: number
): Promise<T> => {
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');
});
});
});
-487
View File
@@ -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<T = any>(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<boolean> {
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 };
-624
View File
@@ -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<T>(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<void>
): Promise<void> {
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 };
-416
View File
@@ -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<T = any>(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<boolean> {
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);
});
});
-194
View File
@@ -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<void>) {
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 };
-445
View File
@@ -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 };
@@ -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);
});
-630
View File
@@ -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);
});
});
-775
View File
@@ -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<K extends keyof MyEvents>(event: K, ...args: Parameters<MyEvents[K]>): boolean {
return super.emit(event, ...args);
}
on<K extends keyof MyEvents>(event: K, listener: MyEvents[K]): this {
return super.on(event, listener);
}
off<K extends keyof MyEvents>(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<T> {
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<string>();
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<number>();
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<string>();
// Items are processed immediately when handlers are added
expect(queue.pending).toBe(0);
});
});
describe('Event Aggregation Pattern', () => {
class EventAggregator<T> {
private events: T[] = [];
private maxEvents: number;
private onFlush: (events: T[]) => void;
private flushTimer: ReturnType<typeof setTimeout> | 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<string>({
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<number>({
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<string>({
maxEvents: 10,
flushInterval: 1000,
onFlush: events => flushed.push(events),
});
aggregator.flush();
expect(flushed).toHaveLength(0);
});
it('should track pending count', () => {
const aggregator = new EventAggregator<string>({
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<string, ((data: unknown) => 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<string, unknown[]> = 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<string, ((data: unknown) => 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<string, ReturnType<typeof setTimeout>> = new Map();
private delays: Map<string, number> = 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<string, Set<(...args: unknown[]) => 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');
});
});
-408
View File
@@ -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 = <T>(arr: T[]): T | undefined => arr[0];
const last = <T>(arr: T[]): T | undefined => arr[arr.length - 1];
const compact = <T>(arr: (T | null | undefined)[]): T[] => {
return arr.filter((x): x is T => x !== null && x !== undefined);
};
const chunk = <T>(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 = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => {
const result = {} as Pick<T, K>;
for (const key of keys) {
if (key in obj) {
result[key] = obj[key];
}
}
return result;
};
const omit = <T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> => {
const result = { ...obj };
for (const key of keys) {
delete result[key];
}
return result as Omit<T, K>;
};
const merge = <T extends object, U extends object>(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 = <T>(arr: T[]): T | undefined => {
if (arr.length === 0) return undefined;
return arr[Math.floor(Math.random() * arr.length)];
};
const shuffle = <T>(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 = <T>(fn: () => T): [T, null] | [null, Error] => {
try {
return [fn(), null];
} catch (e) {
return [null, e as Error];
}
};
const attempt = <T>(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);
});
});
});
-182
View File
@@ -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);
-802
View File
@@ -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 = <T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
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 = <T extends (...args: unknown[]) => void>(
fn: T,
limit: number
): ((...args: Parameters<T>) => void) => {
let inThrottle = false;
let lastArgs: Parameters<T> | null = null;
return (...args: Parameters<T>) => {
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 <T>(
fn: () => Promise<T>,
maxRetries: number,
baseDelay: number
): Promise<T> => {
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 = <T>(
promise: Promise<T>,
timeout: number,
message: string = 'Operation timed out'
): Promise<T> => {
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<string>(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<string>(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<string>(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<string, ReturnType<typeof setInterval>> = 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<typeof setTimeout> | 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<T> {
private intervalId: ReturnType<typeof setInterval> | null = null;
private fn: () => Promise<T>;
private interval: number;
private onResult: (result: T) => void;
private onError: (error: Error) => void;
constructor(options: {
fn: () => Promise<T>;
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<void> {
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<string, number> = {
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');
});
});
});