mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
chore: bump version to 0.1478
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
<img src="docs/screenshots/mobile-with-keyboard.png" alt="Mobile Keyboard" width="280">
|
||||
|
||||
<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
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user