framewatch-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +537 -0
- package/dist/constants.d.ts +172 -0
- package/dist/constants.js +168 -0
- package/dist/constants.js.map +1 -0
- package/dist/engine/browser.d.ts +56 -0
- package/dist/engine/browser.js +142 -0
- package/dist/engine/browser.js.map +1 -0
- package/dist/engine/differ.d.ts +88 -0
- package/dist/engine/differ.js +373 -0
- package/dist/engine/differ.js.map +1 -0
- package/dist/engine/interaction.d.ts +76 -0
- package/dist/engine/interaction.js +254 -0
- package/dist/engine/interaction.js.map +1 -0
- package/dist/engine/layers/console.d.ts +63 -0
- package/dist/engine/layers/console.js +118 -0
- package/dist/engine/layers/console.js.map +1 -0
- package/dist/engine/layers/dom.d.ts +53 -0
- package/dist/engine/layers/dom.js +282 -0
- package/dist/engine/layers/dom.js.map +1 -0
- package/dist/engine/layers/index.d.ts +95 -0
- package/dist/engine/layers/index.js +184 -0
- package/dist/engine/layers/index.js.map +1 -0
- package/dist/engine/layers/network.d.ts +62 -0
- package/dist/engine/layers/network.js +169 -0
- package/dist/engine/layers/network.js.map +1 -0
- package/dist/engine/layers/performance.d.ts +55 -0
- package/dist/engine/layers/performance.js +215 -0
- package/dist/engine/layers/performance.js.map +1 -0
- package/dist/engine/layers/probe.d.ts +50 -0
- package/dist/engine/layers/probe.js +39 -0
- package/dist/engine/layers/probe.js.map +1 -0
- package/dist/engine/layers/session.d.ts +46 -0
- package/dist/engine/layers/session.js +131 -0
- package/dist/engine/layers/session.js.map +1 -0
- package/dist/engine/recorder.d.ts +61 -0
- package/dist/engine/recorder.js +256 -0
- package/dist/engine/recorder.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/accessibility.d.ts +140 -0
- package/dist/tools/accessibility.js +357 -0
- package/dist/tools/accessibility.js.map +1 -0
- package/dist/tools/capture.d.ts +279 -0
- package/dist/tools/capture.js +275 -0
- package/dist/tools/capture.js.map +1 -0
- package/dist/tools/compare.d.ts +86 -0
- package/dist/tools/compare.js +247 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/interact.d.ts +160 -0
- package/dist/tools/interact.js +203 -0
- package/dist/tools/interact.js.map +1 -0
- package/dist/tools/responsive.d.ts +89 -0
- package/dist/tools/responsive.js +197 -0
- package/dist/tools/responsive.js.map +1 -0
- package/dist/tools/screenshot.d.ts +76 -0
- package/dist/tools/screenshot.js +117 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/server.d.ts +89 -0
- package/dist/tools/server.js +201 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bounded-log.d.ts +41 -0
- package/dist/utils/bounded-log.js +78 -0
- package/dist/utils/bounded-log.js.map +1 -0
- package/dist/utils/format.d.ts +56 -0
- package/dist/utils/format.js +130 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/image.d.ts +44 -0
- package/dist/utils/image.js +81 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/server-process.d.ts +84 -0
- package/dist/utils/server-process.js +251 -0
- package/dist/utils/server-process.js.map +1 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FrameWatch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# FrameWatch MCP Server
|
|
2
|
+
|
|
3
|
+
> An MCP server that gives AI coding agents visual eyes — smart frame capture, diffing, and interaction replay for web apps.
|
|
4
|
+
|
|
5
|
+
An AI coding agent can write a CSS transition but not watch it run. It can read the DOM but not see that the modal opened behind the header. FrameWatch closes that gap: it launches a real (headless Chromium) browser and returns what it sees as images an MCP client such as Claude Code looks at natively — alongside the console output, network requests, DOM mutations and paint timings from the same moment.
|
|
6
|
+
|
|
7
|
+
The problem with screenshots as a debugging tool is volume. A five-second recording at 10fps is fifty near-identical images, and fifty images is a flooded context window. So FrameWatch records everything and returns almost none of it: only the frames where something meaningful actually changed, each cropped to the region that changed, each carrying the context that explains why.
|
|
8
|
+
|
|
9
|
+
| Tool | Use it for |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| [`framewatch_screenshot`](#framewatch_screenshot) | What does this page look like right now? |
|
|
12
|
+
| [`framewatch_capture`](#framewatch_capture) | What happens over the next few seconds — animations, loading, a replayed user flow? |
|
|
13
|
+
| [`framewatch_interact`](#framewatch_interact) | Click this, then let me look; then click the next thing. |
|
|
14
|
+
| [`framewatch_responsive`](#framewatch_responsive) | Does it hold up at phone, tablet and desktop widths? |
|
|
15
|
+
| [`framewatch_accessibility`](#framewatch_accessibility) | What would an accessibility auditor flag? |
|
|
16
|
+
| [`framewatch_compare`](#framewatch_compare) | What changed between before and after? |
|
|
17
|
+
| [`framewatch_start_server`](#framewatch_start_server--framewatch_stop_server) / [`framewatch_stop_server`](#framewatch_start_server--framewatch_stop_server) | Get the dev server up so there is something to point at. |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Run directly
|
|
23
|
+
npx framewatch-mcp-server
|
|
24
|
+
|
|
25
|
+
# Register with Claude Code
|
|
26
|
+
claude mcp add framewatch npx framewatch-mcp-server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From a checkout:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install
|
|
33
|
+
npm run build
|
|
34
|
+
claude mcp add framewatch node /absolute/path/to/framewatch/dist/index.js
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Requirements
|
|
38
|
+
|
|
39
|
+
- **Node 20.9+**.
|
|
40
|
+
- **Chromium**, via Playwright. `npm install` normally fetches it; if it did not, run `npx playwright install chromium` once. Every tool detects a missing browser and answers with that exact command rather than a stack trace.
|
|
41
|
+
- **No network access needed** beyond whatever the pages under test require — FrameWatch only ever talks to the URLs it is given.
|
|
42
|
+
|
|
43
|
+
FrameWatch speaks stdio only, and the client spawns it: nothing listens on a port, and there is nothing to configure beyond the line above.
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
### `framewatch_screenshot`
|
|
48
|
+
|
|
49
|
+
Take a single screenshot of a page (or of one element on it). Returns a PNG image content block (resized to max 800px wide) plus a one-line text summary.
|
|
50
|
+
|
|
51
|
+
| Param | Type | Default | Description |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| `url` | string (URL) | — | Page to screenshot, e.g. `http://localhost:3000` |
|
|
54
|
+
| `wait_ms` | integer | `1000` | Wait after page load before capturing |
|
|
55
|
+
| `viewport` | `{ width, height }` | `1280×720` | Viewport size |
|
|
56
|
+
| `selector` | string | — | CSS selector: screenshot only this element |
|
|
57
|
+
| `wait_for` | string | — | CSS selector to wait for (visible) before capturing |
|
|
58
|
+
| `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` / `selector` |
|
|
59
|
+
|
|
60
|
+
**Example call** — the whole page, then one element of it:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "url": "http://localhost:3000/pricing", "wait_ms": 1500 }
|
|
64
|
+
```
|
|
65
|
+
```json
|
|
66
|
+
{ "url": "http://localhost:3000/pricing", "selector": ".plan-card--pro", "wait_for": ".plan-card--pro" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Failures (unreachable URL, selector never appears, invalid input) come back as MCP error results with a readable message rather than crashing the server. Error pages still produce a screenshot, with the HTTP status noted in the summary.
|
|
70
|
+
|
|
71
|
+
### `framewatch_capture`
|
|
72
|
+
|
|
73
|
+
Record a page for a few seconds and return only the frames where something meaningful changed. Each kept frame is a *diff card*: the full frame as a PNG (max 800px wide), a metadata line, and a crop of the changed region with its position. Good for animations, splash/loading screens, transitions, and anything else that changes over time.
|
|
74
|
+
|
|
75
|
+
| Param | Type | Default | Description |
|
|
76
|
+
| --- | --- | --- | --- |
|
|
77
|
+
| `url` | string (URL) | — | Page to record, e.g. `http://localhost:3000` |
|
|
78
|
+
| `duration_ms` | integer 500–30000 | `5000` | How long to record |
|
|
79
|
+
| `sensitivity` | number 0–1 | `0.06` | Change threshold per frame: fraction of an 8×8 grid that must change (`0` keeps every frame, `1` keeps none beyond first/last) |
|
|
80
|
+
| `max_frames` | integer 1–30 | `20` | Maximum diff cards to return |
|
|
81
|
+
| `interval_ms` | integer 16–2000 | `100` | Raw frame capture interval (100 = 10 fps) |
|
|
82
|
+
| `viewport` | `{ width, height }` | `1280×720` | Viewport size |
|
|
83
|
+
| `wait_for` | string | — | CSS selector to wait for (visible) before recording starts |
|
|
84
|
+
| `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
|
|
85
|
+
| `interactions` | array (≤ 50) | — | Interaction script to replay while recording (see below) |
|
|
86
|
+
| `interaction_timeout_ms` | integer ≥ 1 | `10000` | Max time one step may wait for its target element |
|
|
87
|
+
| `include_console` | boolean | `true` | Attach console output and uncaught errors (see [Context layers](#context-layers)) |
|
|
88
|
+
| `include_network` | boolean | `false` | Attach network requests |
|
|
89
|
+
| `include_dom` | boolean | `false` | Attach a summary of the DOM mutations between frames |
|
|
90
|
+
| `include_performance` | boolean | `false` | Attach paint timing, LCP and layout shifts |
|
|
91
|
+
|
|
92
|
+
**Example call** — record a splash screen, keeping more frames than usual because the interesting parts of an animation are small:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "url": "http://localhost:3000", "duration_ms": 5000, "sensitivity": 0.04, "include_console": true }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Recording starts as soon as the navigation commits (not after `load`), so loading and splash animations are captured from their first frame.
|
|
99
|
+
|
|
100
|
+
Returns one summary text block (`Captured 5 meaningful frames from 26 raw frames (2514ms recording) of http://… — "Title"`), then for each card:
|
|
101
|
+
|
|
102
|
+
1. the full frame as a PNG image block;
|
|
103
|
+
2. a text block, e.g. `Frame 2 @ 501ms [animation]` followed by `Changed: 20.0% — region: 80,40 240x160` (percentage of pixels that changed versus the previous card, and the padded bounding box in full-resolution viewport coordinates);
|
|
104
|
+
3. a PNG crop of that region — omitted when the change covers nearly the whole frame.
|
|
105
|
+
|
|
106
|
+
Which raw frames become cards:
|
|
107
|
+
|
|
108
|
+
- the first frame (`[initial]`) and the last frame are always kept;
|
|
109
|
+
- a frame is kept when more than `sensitivity` of the 8×8 grid cells differ from the last kept frame (compared at 320×240 grayscale);
|
|
110
|
+
- frames the recorder captured because of an event (e.g. a `[navigation]`) are always kept;
|
|
111
|
+
- kept frames closer than 200ms are merged, keeping the later, settled one — a continuous animation is thinned to roughly every 200ms;
|
|
112
|
+
- if there are still more than `max_frames`, the first, last and event frames are kept and the rest are sampled evenly by position among the remaining kept frames.
|
|
113
|
+
|
|
114
|
+
The recorder bounds every screenshot, because Chromium blocks them while a navigation is pending or the main thread is busy. A navigation tags the next frame the loop captures rather than requesting an extra screenshot at commit time — that frame reliably shows the new page, and a `history.replaceState` on every animation frame (the usual scroll-spy or router pattern) cannot flood the recording. Fragment-only URL changes are not treated as navigations, and a crashed or closed page ends the recording early with the frames captured so far.
|
|
115
|
+
|
|
116
|
+
Failures come back as MCP error results, like `framewatch_screenshot`.
|
|
117
|
+
|
|
118
|
+
#### Context layers
|
|
119
|
+
|
|
120
|
+
A frame tells you *that* something changed. The context layers tell you *why*. Each one collects timestamped events while the page records, and each diff card is given the events from its own window — everything after the previous card, up to and including its own timestamp. All four start collecting **before** the navigation, so a script that throws on load, the request that never comes back and first paint all land on the first card instead of being missed.
|
|
121
|
+
|
|
122
|
+
| Layer | Flag | What lands on a card |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| Console | `include_console` (on) | `console.*` calls, uncaught exceptions, unhandled rejections, and the tab crashing |
|
|
125
|
+
| Network | `include_network` | One line per request that settled — method, url, status, duration — plus any still in flight when the recording ended |
|
|
126
|
+
| DOM | `include_dom` | Grouped mutations: elements added, removed, restyled, and text edited |
|
|
127
|
+
| Performance | `include_performance` | First Contentful Paint, Largest Contentful Paint, and layout shifts with their summed score |
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Frame 3 @ 1204ms [interaction]
|
|
131
|
+
Changed: 12.4% — region: 40,220 320x180
|
|
132
|
+
Console:
|
|
133
|
+
[error] TypeError: Cannot read properties of null (reading 'id') (at submit (http://localhost:3000/app.js:88:14))
|
|
134
|
+
Network:
|
|
135
|
+
POST http://localhost:3000/api/login → 500 (312ms)
|
|
136
|
+
GET http://localhost:3000/api/me → pending (1204ms)
|
|
137
|
+
Performance:
|
|
138
|
+
layout shifts 2 (score 0.1875)
|
|
139
|
+
DOM:
|
|
140
|
+
+ div.error-banner in form#login
|
|
141
|
+
~ button#submit [disabled]
|
|
142
|
+
~ #spinner [style] ×18
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Paint and LCP are reported in milliseconds since that document's navigation start (the same numbers Lighthouse gives), and only on the card where they were measured — a value that never changes is not repeated down the page. Layout shifts are counted whether or not they followed user input, unlike Chrome's CLS: a jump right after a click is often the one being hunted.
|
|
146
|
+
|
|
147
|
+
Console and network are Playwright events, so they survive a page that has frozen, navigated away or crashed. The DOM and performance layers inject an observer that pushes what it sees back out as it goes, so a navigation mid-recording does not take the old document's records with it. Both skip subframes, and the DOM layer ignores `<head>`, script/link/meta elements and whitespace-only text — none of it says anything about what the page looks like.
|
|
148
|
+
|
|
149
|
+
Every layer is capped so one page cannot flood the response: console keeps 100 entries and network 100 events, evicting ordinary ones to make room for errors and failures; the DOM keeps 500 mutations and renders at most 12 lines per card. The summary says what was collected and what had to be dropped:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Context — console: 12 entries; network: 8 requests (1 still pending); DOM: 340 mutations
|
|
153
|
+
DOM log was capped — 51 mutations dropped.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### Replaying an interaction script
|
|
157
|
+
|
|
158
|
+
Pass `interactions` to drive the page while it records. Each step is `{ action, ... }`, where `delay_ms` is a wait **before** that action, so the delays accumulate over the recording:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"url": "http://localhost:3000/login",
|
|
163
|
+
"duration_ms": 8000,
|
|
164
|
+
"interactions": [
|
|
165
|
+
{ "action": "type", "selector": "#email", "value": "test@example.com", "delay_ms": 1000 },
|
|
166
|
+
{ "action": "type", "selector": "#password", "value": "password123", "delay_ms": 500 },
|
|
167
|
+
{ "action": "click", "selector": "button[type=submit]", "delay_ms": 500 },
|
|
168
|
+
{ "action": "wait", "delay_ms": 3000 }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`framewatch_capture` accepts `click`, `tap`, `type`, `scroll`, `swipe`, `wait` and `navigate`. A frame is forced right after every step, so the result of each action is always kept as an `[interaction]` card. The summary gains a line such as:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
Interactions: 4/4 replayed — type "test@example.com" into "#email", type "password123" into "#password", click "button[type=submit]", wait 3000ms
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Typed values are echoed in that line (elided only when long) and are visible in the frames themselves, so drive these flows with throwaway test credentials — a real password ends up in the tool output and therefore in the model's context.
|
|
180
|
+
|
|
181
|
+
A step that fails ends the script but **not** the capture — the frames recorded up to that point are the most useful thing FrameWatch can hand back, since they show the state the page was actually in. The failure is reported in the summary and the moment it happened is kept as an `[error]` card:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
Interactions: 1/3 replayed — click "#btn". Step 2: click "#nope" failed: locator.click: Timeout 10000ms exceeded.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Touch is enabled automatically (and only) for scripts containing `tap` or `swipe`, because `hasTouch` puts `ontouchstart` on `window` and would otherwise change what a plain capture records. A `swipe` is dispatched as a real finger drag — `touchstart`, ten `touchmove`s about a frame apart, `touchend` — so velocity-sensitive UI (carousels, pull-to-refresh) behaves as it would under a thumb.
|
|
188
|
+
|
|
189
|
+
### `framewatch_interact`
|
|
190
|
+
|
|
191
|
+
Perform **one** interaction and see what it did: before frame, after frame, and a crop of the change. Unlike the other tools this one is stateful on purpose — the page stays open between calls, so you can click, look, type, and look again without replaying the whole flow.
|
|
192
|
+
|
|
193
|
+
| Param | Type | Default | Description |
|
|
194
|
+
| --- | --- | --- | --- |
|
|
195
|
+
| `action` | enum | — | `click`, `tap`, `type`, `scroll`, `swipe`, `navigate`, `select`, `hover` |
|
|
196
|
+
| `selector` | string | — | CSS selector for the target |
|
|
197
|
+
| `value` | string | — | Text to type, option to select, or URL to navigate to |
|
|
198
|
+
| `x`, `y` | number | — | Coordinates for `click`/`tap`/`swipe` when no selector is given |
|
|
199
|
+
| `delta_x`, `delta_y` | number | — | Distance for `scroll` / `swipe` |
|
|
200
|
+
| `url` | string (URL) | — | Open this page first. Omit to act on the page left open by the previous call. |
|
|
201
|
+
| `wait_ms` | integer ≥ 0 | `500` | Settle time after the action, before the "after" screenshot |
|
|
202
|
+
| `timeout_ms` | integer ≥ 1 | `10000` | Max wait for the target element |
|
|
203
|
+
| `viewport` | `{ width, height }` | — | Resize the page (omit to leave it as it is) |
|
|
204
|
+
| `include_console` | boolean | `true` | Report console output and uncaught errors the action caused |
|
|
205
|
+
| `include_network` | boolean | `false` | Report network requests the action caused |
|
|
206
|
+
| `include_dom` | boolean | `false` | Report the DOM mutations the action caused |
|
|
207
|
+
| `include_performance` | boolean | `false` | Report paint timing and layout shifts around the action |
|
|
208
|
+
|
|
209
|
+
**Example call** — a session, one call at a time. Only the first needs a `url`:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "url": "http://localhost:3000/settings", "action": "click", "selector": "nav a[href='/settings/billing']" }
|
|
213
|
+
```
|
|
214
|
+
```json
|
|
215
|
+
{ "action": "type", "selector": "#card-number", "value": "4242424242424242" }
|
|
216
|
+
```
|
|
217
|
+
```json
|
|
218
|
+
{ "action": "click", "selector": "button[type=submit]", "wait_ms": 1500, "include_network": true }
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Returns a summary line (`click "#btn" on http://localhost:3000/ — 8.4% of the frame changed`), the before frame, the after frame with its `Changed: …` metadata, and a crop of the changed region.
|
|
222
|
+
|
|
223
|
+
The same [context layers](#context-layers) as `framewatch_capture` are available here, split over the two frames: the **before** frame carries how the page got into this state (anything it logged or fetched while loading), and the **after** frame carries what the action itself caused. That is usually the fastest way to answer "why did my click do nothing":
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
click "#submit" on http://localhost:3000/login — 0.4% of the frame changed
|
|
227
|
+
Context — console: 1 entry; network: 1 request
|
|
228
|
+
After — Frame 2 @ 612ms [interaction]
|
|
229
|
+
Changed: 0.4% — region: 300,180 60x24
|
|
230
|
+
Console:
|
|
231
|
+
[error] TypeError: Cannot read properties of null (reading 'value')
|
|
232
|
+
Network:
|
|
233
|
+
POST http://localhost:3000/api/login → 422 (88ms)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Because the page is reused between calls, the layers are installed on it once and **emptied at the start of every call**, so each call reports only what it caused. A layer can be switched on mid-session — it is attached to the document that is already open, no reload needed — but one that was on for an earlier call keeps running silently, since a page cannot un-expose an injected observer.
|
|
237
|
+
|
|
238
|
+
Calls are serialised — they all drive the same page, so they queue rather than interleave. The first call needs a `url`; later calls can omit it. Cookies, storage, scroll position and in-page state all carry over. The one thing that cannot change in place is touch support, so the first `tap` or `swipe` on a page opened without it reopens the page — the summary says so, because that resets page state. The session closes when the MCP server shuts down; `framewatch_capture` and `framewatch_screenshot` are unaffected by it, as they always use a fresh, isolated browser context.
|
|
239
|
+
|
|
240
|
+
### `framewatch_responsive`
|
|
241
|
+
|
|
242
|
+
Screenshot one page at several viewport sizes in a single call. Each size loads in its own fresh browser context and they load concurrently, so three 2s waits cost about 2s — and a mobile shot is what a phone would really get, not a resized desktop layout that already ran its `matchMedia` listeners at 1440px.
|
|
243
|
+
|
|
244
|
+
| Param | Type | Default | Description |
|
|
245
|
+
| --- | --- | --- | --- |
|
|
246
|
+
| `url` | string (URL) | — | Page to capture |
|
|
247
|
+
| `viewports` | array (1–8) | mobile 375×812, tablet 768×1024, desktop 1440×900 | `{ name, width, height }` per size |
|
|
248
|
+
| `wait_ms` | integer ≥ 0 | `2000` | Settle time after load, per viewport |
|
|
249
|
+
| `wait_for` | string | — | CSS selector to wait for (visible) at each viewport |
|
|
250
|
+
| `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
|
|
251
|
+
|
|
252
|
+
**Example call** — the three defaults, or your own breakpoints:
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{ "url": "http://localhost:3000" }
|
|
256
|
+
```
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"url": "http://localhost:3000",
|
|
260
|
+
"viewports": [
|
|
261
|
+
{ "name": "iphone-se", "width": 375, "height": 667 },
|
|
262
|
+
{ "name": "ipad-portrait", "width": 768, "height": 1024 },
|
|
263
|
+
{ "name": "macbook", "width": 1440, "height": 900 },
|
|
264
|
+
{ "name": "wide", "width": 1920, "height": 1080 }
|
|
265
|
+
],
|
|
266
|
+
"wait_for": "main"
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Returns a summary, then one labelled image per viewport. Each label also reports **horizontal overflow** — content wider than its viewport — which is the commonest responsive bug and one a screenshot hides, because the part that sticks out is simply cropped off:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
Captured http://localhost:3000/ at 3 of 3 viewports: mobile 375x812, tablet 768x1024, desktop 1440x900
|
|
274
|
+
Horizontal overflow at mobile — content is wider than the viewport, so something is sticking out past the right edge.
|
|
275
|
+
|
|
276
|
+
mobile 375x812 — image 375x812 — horizontal overflow: content is 640px wide in a 375px viewport (+265px) — page scrolls to 1420px (1.7 screens)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
A viewport that fails is reported next to the ones that worked, rather than failing the call — "desktop is fine, mobile times out" is itself the finding. Only a run where *every* viewport failed comes back as an error.
|
|
280
|
+
|
|
281
|
+
### `framewatch_accessibility`
|
|
282
|
+
|
|
283
|
+
Run an [axe-core](https://github.com/dequelabs/axe-core) audit and report the violations. axe is the engine behind most commercial accessibility tooling, so a violation reported here is one an auditor would raise too.
|
|
284
|
+
|
|
285
|
+
| Param | Type | Default | Description |
|
|
286
|
+
| --- | --- | --- | --- |
|
|
287
|
+
| `url` | string (URL) | — | Page to audit |
|
|
288
|
+
| `standard` | `wcag2a` \| `wcag2aa` \| `wcag21aa` | `wcag2aa` | Conformance level to test against |
|
|
289
|
+
| `wait_ms` | integer ≥ 0 | `1000` | Settle time after load, so the app can finish rendering |
|
|
290
|
+
| `wait_for` | string | — | CSS selector to wait for (visible) before auditing |
|
|
291
|
+
| `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
|
|
292
|
+
| `max_violations` | integer 1–25 | `25` | Violation types to report (worst impact first) |
|
|
293
|
+
| `max_elements` | integer 1–20 | `3` | Offending elements listed under each violation |
|
|
294
|
+
| `viewport` | `{ width, height }` | `1280×720` | Some rules (reflow, target size) depend on it |
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
4 WCAG2AA (axe-core 4.13.0) violation types on http://localhost:3000/, affecting 7 elements — 3 critical, 1 serious.
|
|
298
|
+
11 rules passed, 2 need a human to check, 49 did not apply.
|
|
299
|
+
|
|
300
|
+
1. [critical] Images must have alternate text (image-alt) — 2 elements
|
|
301
|
+
Ensures <img> elements have alternate text or a role of none or presentation
|
|
302
|
+
• .hero > img
|
|
303
|
+
<img src="/hero.png" width="800">
|
|
304
|
+
Fix any of the following: Element does not have an alt attribute
|
|
305
|
+
https://dequeuniversity.com/rules/axe/4.13/image-alt
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Example call** — audit at the level you are targeting, waiting for the app to render first:
|
|
309
|
+
|
|
310
|
+
```json
|
|
311
|
+
{ "url": "http://localhost:3000/checkout", "standard": "wcag21aa", "wait_for": "form#checkout" }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Violations are sorted worst impact first (critical → serious → moderate → minor), then by how many elements each affects. Passing rules are counted rather than listed — a list of two hundred rules that did not fire is noise — but "need a human to check" is worth reading: those are the checks axe could not decide on its own, typically colour contrast over an image.
|
|
315
|
+
|
|
316
|
+
The audit runs in a context with CSP bypassed and injects axe into the main frame and every child frame, so an app with a strict `Content-Security-Policy`, or one that embeds an iframe, is audited rather than refused.
|
|
317
|
+
|
|
318
|
+
### `framewatch_compare`
|
|
319
|
+
|
|
320
|
+
Diff two pages — two URLs, or the same URL before and after a code change — and show what moved.
|
|
321
|
+
|
|
322
|
+
| Param | Type | Default | Description |
|
|
323
|
+
| --- | --- | --- | --- |
|
|
324
|
+
| `url_a` | string (URL) or `"current"` | — | The "before" side |
|
|
325
|
+
| `url_b` | string (URL) or `"current"` | — | The "after" side |
|
|
326
|
+
| `wait_ms` | integer ≥ 0 | `2000` | Settle time after load, per side |
|
|
327
|
+
| `wait_for` | string | — | CSS selector to wait for (visible) on both sides |
|
|
328
|
+
| `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
|
|
329
|
+
| `viewport` | `{ width, height }` | `1280×720` | Viewport for both sides |
|
|
330
|
+
|
|
331
|
+
Returns the summary, both frames, and a **diff overlay**: side B with every differing pixel tinted, which is what turns "3.4% changed" into something you can act on.
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
Compared http://localhost:3000/ "Dashboard" against http://localhost:3001/ "Dashboard" at 1280x720 —
|
|
335
|
+
3.42% of pixels differ, all of it within 220,140 480x260.
|
|
336
|
+
|
|
337
|
+
Diff overlay — B with every differing pixel tinted. 31533 of 921600 pixels changed (3.42%), within 220,140 480x260.
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Example call** — two URLs, or the page `framewatch_interact` has open against a fresh one:
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{ "url_a": "http://localhost:3000", "url_b": "https://staging.example.com" }
|
|
344
|
+
```
|
|
345
|
+
```json
|
|
346
|
+
{ "url_a": "current", "url_b": "http://localhost:3000/checkout" }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The comparison is the same pixel comparison the capture engine uses between frames, so a change region here means what it means there. The two sides are captured one after the other rather than at once, so both get an unloaded machine and `wait_ms` means the same thing for each.
|
|
350
|
+
|
|
351
|
+
Pass `"current"` as `url_a` to compare against the page `framewatch_interact` has open, in whatever state your interactions left it — three clicks into a flow, against a plain URL. That page is read, never touched: it is not reloaded, and an explicit `viewport` is ignored for it (with a note saying so), because resizing it would destroy the state being compared. Both sides are then captured at the open page's size, since frames of different sizes cannot be compared pixel for pixel.
|
|
352
|
+
|
|
353
|
+
### `framewatch_start_server` / `framewatch_stop_server`
|
|
354
|
+
|
|
355
|
+
Start the app's dev server so the rest of FrameWatch has something to point at, and stop it again.
|
|
356
|
+
|
|
357
|
+
| Param | Type | Default | Description |
|
|
358
|
+
| --- | --- | --- | --- |
|
|
359
|
+
| `command` | string | — | Shell command, e.g. `npm run dev` |
|
|
360
|
+
| `port` | integer 1–65535 | — | Port the server will listen on |
|
|
361
|
+
| `ready_pattern` | string (regex) | `ready\|started\|listening\|Local:` | Matched against the server's output; the matching line is reported back |
|
|
362
|
+
| `cwd` | string | current directory | Working directory for the command |
|
|
363
|
+
| `env` | object | — | Extra environment variables |
|
|
364
|
+
| `timeout_ms` | integer 100–300000 | `30000` | Max wait for the port to start answering |
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
{ "status": "running", "port": 3000, "pid": 51234, "url": "http://localhost:3000", "ready_ms": 2140,
|
|
368
|
+
"ready_line": " ready in 812 ms — Local: http://localhost:3000/" }
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Example call:**
|
|
372
|
+
|
|
373
|
+
```json
|
|
374
|
+
{ "command": "npm run dev", "port": 5173, "cwd": "/Users/me/code/my-app" }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Readiness is the port, not the log line.** `ready_pattern` is matched and quoted back — dev servers print the URL they actually bound to, which is worth repeating — but what makes a server "running" is that something answers on its port. That is the condition the next tool call depends on, and a pattern that fires early (or a regex that never matches a server working perfectly) would report the wrong thing in both directions. Both loopback addresses are probed, so a server bound only to `::1` is not mistaken for one that never started.
|
|
378
|
+
|
|
379
|
+
If the command exits first, or the port never opens, the error carries the server's own output — which is where the reason actually is:
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
Start server failed: `npm run dev` on port 3000 — the server exited before port 3000 opened (exit code 1).
|
|
383
|
+
Last output from the server:
|
|
384
|
+
npm ERR! Missing script: "dev"
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
One server runs at a time. Starting the same command and port again is a no-op that reports the running server; starting a *different* one while it is up is an error naming what to stop first. A port already in use by something else is refused up front, rather than reported as an instant success. `framewatch_stop_server` takes no arguments and reports `not_running` rather than failing when there is nothing to stop; it signals the whole process group, so `npm run dev` → node → bundler all go together. FrameWatch also stops the server when the MCP server shuts down.
|
|
388
|
+
|
|
389
|
+
## Putting it together
|
|
390
|
+
|
|
391
|
+
A typical session — the agent brings the app up, records a flow, drives the page by hand, then checks the result. The output below is the real shape of what comes back, image blocks omitted:
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
framewatch_start_server { "command": "npm run dev", "port": 5173 }
|
|
395
|
+
→ { "status": "running", "port": 5173, "url": "http://localhost:5173", "ready_ms": 1840,
|
|
396
|
+
"ready_line": " VITE v6.0.3 ready in 812 ms — Local: http://localhost:5173/" }
|
|
397
|
+
|
|
398
|
+
framewatch_capture { "url": "http://localhost:5173/login", "duration_ms": 6000,
|
|
399
|
+
"interactions": [
|
|
400
|
+
{ "action": "type", "selector": "#email", "value": "test@example.com", "delay_ms": 1000 },
|
|
401
|
+
{ "action": "type", "selector": "#password", "value": "hunter2", "delay_ms": 500 },
|
|
402
|
+
{ "action": "click", "selector": "#submit", "delay_ms": 500 },
|
|
403
|
+
{ "action": "wait", "delay_ms": 2000 }
|
|
404
|
+
],
|
|
405
|
+
"include_network": true, "include_dom": true }
|
|
406
|
+
|
|
407
|
+
→ Captured 7 meaningful frames from 66 raw frames (6040ms recording) of http://localhost:5173/login — "Sign in"
|
|
408
|
+
Interactions: 4/4 replayed — type "test@example.com" into "#email", type "hunter2" into "#password", click "#submit", wait 2000ms
|
|
409
|
+
Context — console: 2 entries; network: 2 requests; DOM: 282 mutations
|
|
410
|
+
|
|
411
|
+
Frame 1 @ 30ms [initial]
|
|
412
|
+
Network:
|
|
413
|
+
GET http://localhost:5173/login → 200 (4ms)
|
|
414
|
+
DOM:
|
|
415
|
+
+ form#login in body
|
|
416
|
+
+ input#email in form#login
|
|
417
|
+
… and 4 more changes across 4 elements
|
|
418
|
+
|
|
419
|
+
…
|
|
420
|
+
|
|
421
|
+
Frame 4 @ 2147ms [interaction]
|
|
422
|
+
Changed: 3.7% — region: 20,115 397x128
|
|
423
|
+
DOM:
|
|
424
|
+
~ button#submit [disabled]
|
|
425
|
+
~ div#spinner [class]
|
|
426
|
+
|
|
427
|
+
Frame 5 @ 2316ms [animation]
|
|
428
|
+
Changed: 10.3% — region: 20,20 397x275
|
|
429
|
+
Console:
|
|
430
|
+
[error] Failed to load resource: the server responded with a status of 401 (Unauthorized)
|
|
431
|
+
[error] login failed: Invalid email or password
|
|
432
|
+
Network:
|
|
433
|
+
POST http://localhost:5173/api/login → 401 (123ms)
|
|
434
|
+
DOM:
|
|
435
|
+
~ div#spinner [class]
|
|
436
|
+
~ button#submit [disabled]
|
|
437
|
+
|
|
438
|
+
framewatch_interact { "url": "http://localhost:5173/login", "action": "type", "selector": "#email", "value": "test@example.com" }
|
|
439
|
+
framewatch_interact { "action": "type", "selector": "#password", "value": "correct-horse" }
|
|
440
|
+
framewatch_interact { "action": "click", "selector": "#submit", "wait_ms": 1500 }
|
|
441
|
+
→ click "#submit" on http://localhost:5173/login — 74.2% of the frame changed
|
|
442
|
+
|
|
443
|
+
framewatch_accessibility { "url": "http://localhost:5173/dashboard", "standard": "wcag21aa" }
|
|
444
|
+
framewatch_stop_server {}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Two things worth noticing. The agent did not get 66 screenshots and a guess: it got the seven moments the page actually changed, the 401 that caused frame 5, and the `#spinner` and `#submit` changes that came with it. And the failure landed on an `[animation]` frame rather than an `[interaction]` one — the click is what FrameWatch forces a frame for, but the response arrived 170ms later, and the change detector caught it on its own.
|
|
448
|
+
|
|
449
|
+
`framewatch_interact` starts its own session and does not inherit the page `framewatch_capture` used, so its first call carries a `url`; the ones after it do not need one.
|
|
450
|
+
|
|
451
|
+
## Troubleshooting
|
|
452
|
+
|
|
453
|
+
**"Playwright's Chromium browser is not installed."** Run `npx playwright install chromium`. Every tool returns this message with the command in it rather than a stack trace.
|
|
454
|
+
|
|
455
|
+
**A capture came back with only two frames.** The page changed less than `sensitivity` between them. Lower it — `0.02` catches subtle fades, `0.06` (the default) is tuned to ignore cursor blinks. If the page genuinely settles instantly, two frames is the correct answer.
|
|
456
|
+
|
|
457
|
+
**A capture came back with `max_frames` frames of noise.** Something is animating continuously — a spinner, a video, a carousel. Raise `sensitivity` so only larger changes qualify, shorten `duration_ms`, or capture the specific moment with `framewatch_interact` instead.
|
|
458
|
+
|
|
459
|
+
**The frames are blank or show a loading skeleton.** Recording starts at navigation commit, which is the point for splash screens but not when you wanted the finished page. Pass `wait_for` with a selector that only exists once the app has rendered.
|
|
460
|
+
|
|
461
|
+
**An accessibility audit found nothing on a page you know is broken.** axe ran before the app rendered. Add `wait_for`, or raise `wait_ms` past the point where the content appears.
|
|
462
|
+
|
|
463
|
+
**`framewatch_interact` says the page was reopened.** Touch support cannot be switched on in place, so the first `tap` or `swipe` on a page opened without it reopens the page and resets its state. Put the `tap` in the first call, or drive the flow with `framewatch_capture` instead.
|
|
464
|
+
|
|
465
|
+
**`framewatch_start_server` refuses with "port already in use".** Something is already listening — often a dev server from an earlier session. Stop it, or point FrameWatch at it directly and skip `framewatch_start_server` altogether.
|
|
466
|
+
|
|
467
|
+
**Everything times out on a page behind a login.** Use `framewatch_interact` to sign in first; the session page keeps cookies and storage between calls, and `framewatch_compare` can read it with `"url_a": "current"`.
|
|
468
|
+
|
|
469
|
+
## Development
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
npm install
|
|
473
|
+
npm run build # tsc → dist/
|
|
474
|
+
npm test # builds + type-checks, then runs vitest (unit + stdio integration tests)
|
|
475
|
+
npm run typecheck # type-check src/ and test/ without emitting
|
|
476
|
+
npm run coverage # vitest with v8 coverage
|
|
477
|
+
npm run dev # tsc --watch
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Tests launch real Chromium against local fixture pages in `test/fixtures/`, served over HTTP by `test/helpers/fixture-server.ts` (which also answers the `/api/*` endpoints the fixtures call, including one request that is deliberately never answered):
|
|
481
|
+
|
|
482
|
+
| Fixture | What it is for |
|
|
483
|
+
| --- | --- |
|
|
484
|
+
| `basic.html`, `busy.html` | A still page, and one that never stops repainting |
|
|
485
|
+
| `splash.html` | A deterministic JS-timed animation — the frame-selection tests |
|
|
486
|
+
| `interactive.html` | Click/type/select/hover/touch/scroll targets, each a fixed-size solid block |
|
|
487
|
+
| `login.html` | A real form: validation, a POST that 401s, an error banner, a transition on success |
|
|
488
|
+
| `console.html`, `network.html`, `dom.html`, `perf.html` | One context layer each |
|
|
489
|
+
| `interact-context.html` | The layers on the long-lived `framewatch_interact` page |
|
|
490
|
+
| `recorder-flip.html`, `recorder-spa.html`, `recorder-target.html`, `redirect.html` | Navigation, `history.pushState`, and redirects |
|
|
491
|
+
| `responsive.html`, `responsive-late.html` | Breakpoints and overflow, at load and 400ms after it |
|
|
492
|
+
| `a11y-good.html`, `a11y-bad.html`, `a11y-frame.html`, `a11y-late.html`, `csp.html` | Clean, broken, broken-inside-an-iframe, broken-after-render, and behind a strict CSP |
|
|
493
|
+
| `compare-a.html` / `compare-b.html` | Two pages that differ in one region |
|
|
494
|
+
|
|
495
|
+
The dev-server tests drive `test/helpers/fake-dev-server.mjs`, a stand-in that can be told to start slowly, never open its port, exit with an error, or ignore SIGTERM. `test/packaging.test.ts` packs the tarball, unpacks it and speaks MCP to the binary inside, which is the closest thing to testing `npx framewatch-mcp-server` without publishing. The stdio integration test screenshots `https://example.com`, so that one needs network access.
|
|
496
|
+
|
|
497
|
+
Two things the coverage report will not show you. The DOM and performance probes run *inside* Chromium, so Node's V8 coverage cannot see them even though `context.test.ts` asserts on everything they emit; and `src/index.ts` plus the `register*Tool` functions run in a child process, so `server.test.ts` exercises them without registering a line. Read those numbers as "uninstrumented", not "untested".
|
|
498
|
+
|
|
499
|
+
## Architecture
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
MCP client (Claude Code) ◄─stdio─► FrameWatch server ◄──► Playwright Chromium
|
|
503
|
+
│
|
|
504
|
+
recorder → smart diff
|
|
505
|
+
│
|
|
506
|
+
sharp (resize / crop)
|
|
507
|
+
│
|
|
508
|
+
context layers (console / network / DOM / performance)
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
- `src/index.ts` — McpServer + stdio transport; shuts the browser down on stdin EOF / SIGINT / SIGTERM / SIGHUP
|
|
512
|
+
- `src/engine/browser.ts` — one shared Chromium: a fresh context per tool call, plus the long-lived page `framewatch_interact` works on
|
|
513
|
+
- `src/engine/recorder.ts` — captures raw PNG frames at a fixed interval (plus forced frames on navigation)
|
|
514
|
+
- `src/engine/differ.ts` — grid-based frame selection, pixel-level change regions, crops
|
|
515
|
+
- `src/engine/interaction.ts` — validates and executes one interaction step (click, tap, type, scroll, swipe, hover, select, navigate)
|
|
516
|
+
- `src/engine/layers/` — the context layers: `console.ts`, `network.ts` (Playwright events), `dom.ts`, `performance.ts` (injected observers), `probe.ts` (the injection plumbing they share), `session.ts` (layers for the long-lived interact page), and `index.ts` (attach, drain, split across cards)
|
|
517
|
+
- `src/utils/image.ts` — sharp wrappers, including the compare overlay
|
|
518
|
+
- `src/utils/bounded-log.ts` — capped log that evicts ordinary entries to keep errors
|
|
519
|
+
- `src/utils/server-process.ts` — dev server process manager (spawn, port readiness, process-group kill)
|
|
520
|
+
- `src/utils/format.ts` — turns diff cards into MCP content blocks
|
|
521
|
+
- `src/tools/screenshot.ts` — the screenshot tool
|
|
522
|
+
- `src/tools/capture.ts` — the capture tool, including interaction replay
|
|
523
|
+
- `src/tools/interact.ts` — the single-interaction tool
|
|
524
|
+
- `src/tools/responsive.ts` — multi-viewport capture with the overflow check
|
|
525
|
+
- `src/tools/accessibility.ts` — the axe-core audit
|
|
526
|
+
- `src/tools/compare.ts` — before/after comparison and the diff overlay
|
|
527
|
+
- `src/tools/server.ts` — the two dev-server tools
|
|
528
|
+
|
|
529
|
+
All diagnostics go to stderr; stdout is reserved for the MCP protocol.
|
|
530
|
+
|
|
531
|
+
## Contributing
|
|
532
|
+
|
|
533
|
+
Issues and pull requests are welcome at [github.com/kekoDev/framewatch](https://github.com/kekoDev/framewatch). `npm test` builds, type-checks and runs the whole suite against real Chromium; please make sure it is green, and add a fixture rather than a mock when the behaviour involves a page.
|
|
534
|
+
|
|
535
|
+
## License
|
|
536
|
+
|
|
537
|
+
[MIT](LICENSE)
|