Skip to content
105 changes: 80 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Built with **FastMCP** leveraging the latest MCP spec (2025-11-25) features.

## ✨ What It Does

```
```text
YOU: "Watch the modal animation on this page"
↓
ANIMAWATCH:
Expand Down Expand Up @@ -41,6 +41,12 @@ RESULT: "Jank detected at 1.2s - fade-in stutters for 180ms"
| `analyze_video` | πŸŽ₯ Analyze an existing video file |
| `record` | ⏺️ Just record without analysis |
| `check_accessibility` | β™Ώ Visual accessibility analysis |
| `list_devices` | πŸ“± List available device profiles for emulation |
| `watch_with_device` | πŸ“² Watch with mobile/tablet device emulation |
| `compare_screenshots` | πŸ” Visual diff comparison between two URLs |
| `analyze_fps` | 🎯 FPS consistency and jank detection |
| `get_performance_metrics` | πŸ“Š Core Web Vitals (LCP, FCP, CLS, TTFB) |
| `analyze_with_consensus_tool` | 🀝 Multi-model consensus analysis |

### Resources

Expand Down Expand Up @@ -84,6 +90,7 @@ cp .env.example .env
### 4. Add to Your MCP Client

**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):

```json
{
"mcpServers": {
Expand All @@ -99,6 +106,7 @@ cp .env.example .env
```

**Augment Code** (settings):

```json
{
"mcpServers": {
Expand All @@ -116,30 +124,71 @@ cp .env.example .env
## πŸ“– Usage Examples

### Watch Animation Issues
```

```text
"Watch the modal animation on https://example.com for any jank"
```

### Perform Actions Then Watch
```

```text
"Click the hamburger menu on https://example.com and watch the slide-in animation"
```

### Focus on Specific Area
```

```text
"Watch https://example.com with focus on scroll behavior"
```

### Accessibility Check
```

```text
"Check accessibility on https://example.com"
```

### Access Previous Results
```

```text
"Show me the analysis from animawatch://analyses/abc123"
```

### Test on Mobile Device

```text
"Watch https://example.com on an iPhone 15 Pro and check for animation issues"
```

### Compare Before/After

```text
"Compare screenshots of https://staging.example.com and https://example.com for visual differences"
```

### Check Performance Metrics

```text
"Get Core Web Vitals for https://example.com"
```

### Analyze FPS

```text
"Analyze the FPS of this video recording for frame drops"
```

### Multi-Model Consensus

```text
"Analyze https://example.com using both Gemini and Ollama for higher accuracy"
```

### List Available Devices

```text
"What mobile devices can I test with?"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

## πŸ”§ Configuration

| Environment Variable | Default | Description |
Expand Down Expand Up @@ -180,25 +229,31 @@ export OLLAMA_MODEL=qwen2.5-vl:7b

## πŸ—οΈ Architecture

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ AnimaWatch β”‚
β”‚ (FastMCP Server) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Lifespan Context (AppContext) β”‚
β”‚ β”œβ”€β”€ BrowserRecorder (Playwright) β”‚
β”‚ β”œβ”€β”€ VisionProvider (Gemini/Ollama) β”‚
β”‚ β”œβ”€β”€ recordings: dict[id, Path] β”‚
β”‚ └── analyses: dict[id, str] β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Tools β”‚ Resources β”‚ Prompts β”‚
β”‚ ───────── β”‚ ───────────────────── β”‚ ────────────── β”‚
β”‚ watch β”‚ animawatch://recordingsβ”‚ animation_diagnosisβ”‚
β”‚ screenshot β”‚ animawatch://analyses β”‚ page_analysis β”‚
β”‚ record β”‚ animawatch://config β”‚ accessibility_checkβ”‚
β”‚ analyze_videoβ”‚ β”‚ β”‚
β”‚ check_accessβ”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ AnimaWatch β”‚
β”‚ (FastMCP Server) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Lifespan Context (AppContext) β”‚
β”‚ β”œβ”€β”€ BrowserRecorder (Playwright) β”‚
β”‚ β”œβ”€β”€ VisionProvider (Gemini/Ollama) β”‚
β”‚ β”œβ”€β”€ recordings: dict[id, Path] β”‚
β”‚ └── analyses: dict[id, str] β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Core Tools β”‚ Device & Performance β”‚ Comparison & Accuracy β”‚
β”‚ ────────────────── β”‚ ──────────────────── β”‚ ──────────────────── β”‚
β”‚ watch β”‚ list_devices β”‚ compare_screenshots β”‚
β”‚ screenshot β”‚ watch_with_device β”‚ analyze_with_consensus β”‚
β”‚ record β”‚ analyze_fps β”‚ β”‚
β”‚ analyze_video β”‚ get_performance_metricsβ”‚ β”‚
β”‚ check_accessibility β”‚ β”‚ β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Resources β”‚ Prompts β”‚
β”‚ ─────────────────────────── β”‚ ─────────────────────────────────────── β”‚
β”‚ animawatch://recordings/{id} β”‚ animation_diagnosis β”‚
β”‚ animawatch://analyses/{id} β”‚ page_analysis β”‚
β”‚ animawatch://config β”‚ accessibility_check β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
```

## πŸ“„ License
Expand Down
27 changes: 25 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,37 @@ requires-python = ">=3.11"
authors = [
{ name = "Rishi Tank", email = "3771081+rishitank@users.noreply.github.com" }
]
keywords = ["mcp", "animation", "visual-testing", "playwright", "gemini", "ai-vision", "ui-testing", "fastmcp"]
keywords = [
"mcp",
"model-context-protocol",
"animation",
"visual-testing",
"playwright",
"gemini",
"ai-vision",
"ui-testing",
"fastmcp",
"web-vitals",
"performance",
"accessibility",
"browser-automation",
"claude",
"llm",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Quality Assurance",
"Topic :: Internet :: WWW/HTTP :: Browsers",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Typing :: Typed",
]

dependencies = [
Expand Down
32 changes: 13 additions & 19 deletions src/animawatch/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from dataclasses import dataclass
from pathlib import Path

from PIL import Image, ImageChops, ImageDraw
from PIL import Image, ImageChops


@dataclass
Expand Down Expand Up @@ -74,7 +74,7 @@ def compare_images(
diff_sum = 0

# Get pixel data as a flat list for grayscale image
pixel_data = list(diff_gray.getdata())
pixel_data = diff_gray.tobytes()
for pixel_value in pixel_data:
# For grayscale ("L" mode), pixels are single int values
pixel_diff = int(pixel_value)
Expand Down Expand Up @@ -129,7 +129,7 @@ def _find_diff_regions(

# Calculate region difference score
region = diff_gray.crop(bbox)
pixels = list(region.getdata())
pixels = region.tobytes()
avg_diff = sum(pixels) / len(pixels) if pixels else 0
difference_score = (avg_diff / 255) * 100

Expand All @@ -152,22 +152,16 @@ def _generate_diff_image(
highlight_color: tuple[int, int, int, int],
) -> Path:
"""Generate a diff image with highlighted changes."""
# Create a copy of the "after" image
result = after.copy()

# Create an overlay for highlighting
overlay = Image.new("RGBA", result.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)

# Highlight differing pixels using getpixel for type safety
for x in range(diff_gray.width):
for y in range(diff_gray.height):
pixel_value = diff_gray.getpixel((x, y))
if isinstance(pixel_value, int) and pixel_value > threshold:
draw.point((x, y), fill=highlight_color)

# Composite the overlay onto the result
result = Image.alpha_composite(result.convert("RGBA"), overlay)
# Mask of differing pixels: 255 where the difference exceeds the threshold.
# Image.point runs in C, unlike a per-pixel getpixel loop in Python.
mask = diff_gray.point(lambda p: 255 if p > threshold else 0)

# Transparent overlay with the highlight colour painted only under the mask
overlay = Image.new("RGBA", after.size, (0, 0, 0, 0))
overlay.paste(highlight_color, (0, 0, *after.size), mask)

# Composite the overlay onto the "after" image
result = Image.alpha_composite(after.convert("RGBA"), overlay)

# Save to temp file
fd, tmp_path = tempfile.mkstemp(suffix="_diff.png")
Expand Down
60 changes: 60 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Shared pytest fixtures for AnimaWatch tests."""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest

from animawatch.server import AppContext


@pytest.fixture
def mock_page() -> MagicMock:
"""A Playwright ``Page`` stand-in with awaitable navigation and evaluation."""
page = MagicMock()
page.goto = AsyncMock()
page.evaluate = AsyncMock(return_value={})
return page


@pytest.fixture
def mock_app_context(mock_page: MagicMock, tmp_path: Path) -> AppContext:
"""An AppContext whose browser and vision provider are mocks.

``browser.pooled_context`` behaves like the real async context manager: calling
it returns an object usable in ``async with`` that yields ``(context, page)``.
It is wrapped in a ``MagicMock`` so tests can assert how it was called.
"""
mock_browser = AsyncMock()
# Paths live under tmp_path so tools that unlink them can't touch real files.
mock_browser.record_interaction = AsyncMock(return_value=tmp_path / "video.webm")
mock_browser.take_screenshot = AsyncMock(return_value=tmp_path / "screenshot.png")

@asynccontextmanager
async def pooled_context(*_args: Any, **_kwargs: Any) -> AsyncIterator[tuple[Any, Any]]:
"""Yield a ``(context, page)`` pair like ``BrowserRecorder.pooled_context``."""
yield (MagicMock(), mock_page)

mock_browser.pooled_context = MagicMock(side_effect=pooled_context)

mock_vision = AsyncMock()
mock_vision.analyze_video = AsyncMock(return_value="Video analysis result")
mock_vision.analyze_image = AsyncMock(return_value="Image analysis result")

return AppContext(
browser=mock_browser,
vision=mock_vision,
recordings={},
analyses={},
)


@pytest.fixture
def mock_ctx(mock_app_context: AppContext) -> MagicMock:
"""A tool ``Context`` whose lifespan context is ``mock_app_context``."""
ctx = MagicMock()
ctx.request_context.lifespan_context = mock_app_context
return ctx
42 changes: 41 additions & 1 deletion tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pathlib import Path

import pytest
from PIL import Image
from PIL import Image, ImageChops

from animawatch.diff import (
DiffRegion,
Expand Down Expand Up @@ -113,6 +113,46 @@ def test_diff_image_generation(self, different_images: tuple[Path, Path]) -> Non
assert result.diff_image_path.exists()
result.diff_image_path.unlink(missing_ok=True)

@pytest.mark.parametrize(
"highlight_color", [(0, 0, 255, 255), (255, 0, 0, 128)], ids=["opaque", "translucent"]
)
def test_diff_image_highlights_exactly_pixels_above_threshold(
self, tmp_path: Path, highlight_color: tuple[int, int, int, int]
) -> None:
"""The diff image is the "after" image with only above-threshold pixels overlaid."""
threshold = 5
before = Image.new("RGB", (50, 50), "white")
after = before.copy()
after.paste((255, 0, 0), (10, 10, 20, 20)) # strong difference
after.paste((250, 250, 250), (30, 30, 40, 40)) # grey diff of exactly `threshold`
before_path, after_path = tmp_path / "before.png", tmp_path / "after.png"
before.save(before_path)
after.save(after_path)

result = compare_images(
before_path, after_path, threshold=threshold, highlight_color=highlight_color
)
assert result.diff_image_path is not None
try:
actual = Image.open(result.diff_image_path).convert("RGBA")

# Reference: overlay the highlight pixel by pixel wherever the grayscale
# difference is strictly greater than the threshold.
diff_gray = ImageChops.difference(before, after).convert("L")
overlay = Image.new("RGBA", after.size, (0, 0, 0, 0))
for x in range(after.width):
for y in range(after.height):
value = diff_gray.getpixel((x, y))
if isinstance(value, int) and value > threshold:
overlay.putpixel((x, y), highlight_color)
expected = Image.alpha_composite(after.convert("RGBA"), overlay)

assert actual.tobytes() == expected.tobytes()
assert actual.getpixel((35, 35)) == (250, 250, 250, 255) # at threshold: untouched
assert actual.getpixel((0, 0)) == (255, 255, 255, 255) # unchanged: untouched
finally:
result.diff_image_path.unlink(missing_ok=True)

def test_result_paths(self, identical_images: tuple[Path, Path]) -> None:
"""Test that result contains correct paths."""
before, after = identical_images
Expand Down
Loading
Loading