Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The design is **consumer-agnostic**: the core handles protocol, tooling, and kno
│ ┌──────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Routes │ │ RequestQueue │ │ Tool │ │ Knowledge │ │
│ │ /health │ │ (async mutex) │ │ Registry │ │ Store │ │
│ │ /status │ │ │ │ 30 tools │ │ │ │
│ │ /status │ │ │ │ 31 tools │ │ │ │
│ │ /launch │ └───────────────┘ └─────┬──────┘ └────────────┘ │
│ │ /cleanup │ │ │
│ │ /tool/:n │ ▼ │
Expand Down Expand Up @@ -404,6 +404,7 @@ The daemon routes `POST /tool/:name` requests through the registry, applies Zod
| `run_steps` | Executes a batch of tool invocations sequentially. Supports `stopOnError` to halt on first failure, `includeObservations` (`'all'`, `'none'`, `'failures'`) to control observations, and `batchTimeoutMs` to set an overall deadline (remaining steps are skipped on timeout). Accepts tool aliases like `navigate_home` / `navigate-home`. Returns per-step results with timing. |
| **Advanced** | |
| `mock_network` | Adds, clears, lists, and inspects targeted Playwright network mocks on the active browser context. Unmatched same-origin requests are continued unchanged. |
| `mock_websocket` | Adds, clears, lists, and inspects targeted WebSocket mocks using Playwright's `routeWebSocket` API. Intercepts WebSocket connections by exact URL, matches incoming messages against rules, and sends scripted responses. Supports passthrough mode (default) to forward unmatched messages to the real server, or full mock mode with no real connection. |
| `cdp` | Sends a raw Chrome DevTools Protocol command against the active page. Escape hatch for cases where structured tools are insufficient (e.g., `Runtime.evaluate`, `Network.enable`). A small set of destructive methods (`Browser.close`, `Target.closeTarget`, etc.) are blocked to protect session state. Categorized as mutating — run `describe_screen` afterward to re-sync. |

### Accessibility References
Expand Down Expand Up @@ -612,6 +613,10 @@ mm describe-screen
| `mm mock-network clear` | Clears route mocks and recorded requests. |
| `mm mock-network list` | Lists active route mocks. |
| `mm mock-network requests [--limit <n>]` | Shows recorded matched and missed requests. |
| `mm mock-websocket add '<json-mock-definition>'` | Adds a targeted WebSocket mock by exact `ws://` or `wss://` URL. Pass a single mock object, an array of mocks, or an object with a `mocks` array. |
| `mm mock-websocket clear` | Clears all WebSocket mocks, message records, and closes active intercepted connections. |
| `mm mock-websocket list` | Lists currently registered WebSocket mock definitions. |
| `mm mock-websocket messages [--limit <n>]` | Shows recorded WebSocket message hits and misses with direction, matched rule, and timestamps. |
| `mm cdp <method> [params-json] [--timeout <ms>]` | Sends a raw Chrome DevTools Protocol command against the active page. Escape hatch for when structured tools are insufficient. Destructive methods (`Browser.close`, etc.) are blocked. |

```bash
Expand Down Expand Up @@ -679,6 +684,7 @@ Tool errors are classified into specific error codes for structured handling:
| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded in run_steps |
| `MM_CDP_BLOCKED` | CDP method is blocked (destructive to session) |
| `MM_CDP_FAILED` | CDP command execution failed or timed out |
| `MM_MOCK_WEBSOCKET_FAILED` | WebSocket mock operation failed |

## Development

Expand Down
72 changes: 66 additions & 6 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ mm cleanup --shutdown # 5. Clean up when done

Tool responses include different data based on the tool's category:

| Category | Examples | Observations in response? |
| ------------- | --------------------------------------------------------------------------- | ---------------------------------------------- |
| **Mutating** | click, type, navigate, launch, cleanup, build, clipboard, cdp, mock_network | Yes — `state` + `a11y` (compacted) + `testIds` |
| **Read-only** | get_state, get_text, knowledge\_\*, get_context, set_context | No — faster response |
| **Discovery** | describe_screen, list_testids, accessibility_snapshot, screenshot | Data is already in `result` |
| **Batch** | run_steps | Controlled by `includeObservations` param |
| Category | Examples | Observations in response? |
| ------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| **Mutating** | click, type, navigate, launch, cleanup, build, clipboard, cdp, mock_network, mock_websocket | Yes — `state` + `a11y` (compacted) + `testIds` |
| **Read-only** | get_state, get_text, knowledge\_\*, get_context, set_context | No — faster response |
| **Discovery** | describe_screen, list_testids, accessibility_snapshot, screenshot | Data is already in `result` |
| **Batch** | run_steps | Controlled by `includeObservations` param |

**Observation Compaction:** Mutating tool observations are **compacted** before returning: option runs of 3 or more under a combobox or listbox are replaced with a single summary node (e.g., `"55 options (refs e2–e56)"`). The `describe-screen` tool always returns the **full, unfiltered** a11y tree — use it when you need the complete option list or `priorKnowledge`.

Expand Down Expand Up @@ -549,6 +549,65 @@ mm mock-network requests [--limit <n>]
| ------------- | ------------------------------------------ |
| `--limit <n>` | Maximum number of recent records to return |

#### `mm mock-websocket add '<json-mock-definition>'`

Adds a targeted WebSocket mock during an active session. Each mock intercepts connections to an exact `ws://` or `wss://` URL, matches incoming messages against rules, and sends scripted responses. Unmatched messages are forwarded to the real server by default (passthrough mode).

```bash
mm mock-websocket add '{"url":"wss://api.example.com/ws","rules":[{"id":"sub","match":{"includes":"subscribe"},"respond":{"channel":"prices","data":[]}}]}'
```

A mock definition requires:

| Field | Description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `url` | Exact `ws://` or `wss://` URL to intercept (no wildcards) |
| `rules` | Array of message matching rules (at least one) |
| `rules[].id` | Stable identifier for the rule |
| `rules[].match` | Object with `includes` — a string or array of strings that must appear in the incoming message (all must match) |
| `rules[].respond` | JSON payload to send back to the page (optional) |
| `rules[].delay` | Milliseconds before sending the response (0–30000, optional) |
| `rules[].followUpResponse` | Second JSON payload sent after `followUpDelay` (optional) |
| `rules[].followUpDelay` | Milliseconds before sending the follow-up response (0–30000, optional) |
| `passthrough` | Connect to real server and forward unmatched messages (default: `true`). Set `false` for full mock mode. |

You can also pass an array of mocks or an object with a `mocks` array:

```bash
mm mock-websocket add '[{"url":"wss://a.com/ws","rules":[...]},{"url":"wss://b.com/ws","rules":[...]}]'
mm mock-websocket add '{"mocks":[...]}'
```

Adding a mock with the same URL as an existing one replaces it and closes active connections for that URL.

#### `mm mock-websocket clear`

Clears all WebSocket mocks, message records, and closes active intercepted connections.

```bash
mm mock-websocket clear
```

#### `mm mock-websocket list`

Lists currently registered WebSocket mock definitions.

```bash
mm mock-websocket list
```

#### `mm mock-websocket messages`

Shows recorded WebSocket message hits and misses with direction, matched rule ID, and timestamps.

```bash
mm mock-websocket messages [--limit <n>]
```

| Flag | Description |
| ------------- | ------------------------------------------ |
| `--limit <n>` | Maximum number of recent records to return |

#### `mm cdp <method> [params-json] [--timeout <ms>]`

Sends a raw Chrome DevTools Protocol command against the active page. This is an escape hatch for cases where structured tools are insufficient — e.g., evaluating JavaScript, enabling network tracking, or inspecting the DOM tree directly.
Expand Down Expand Up @@ -634,6 +693,7 @@ When a command fails, the response includes `error.code`. Use this to decide wha
| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded | Remaining steps were skipped; check partial results |
| `MM_CDP_BLOCKED` | CDP method is blocked (destructive) | Use a different CDP method; see blocked list |
| `MM_CDP_FAILED` | CDP command failed or timed out | Check method name/params; retry or increase timeout |
| `MM_MOCK_WEBSOCKET_FAILED` | WebSocket mock operation failed | Check mock definition format; verify session is active |

## Available Contracts (E2E only)

Expand Down
98 changes: 98 additions & 0 deletions src/cli/mm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import {
sendRequest,
routeCommand,
routeMockNetworkCommand,
routeMockWebSocketCommand,
parseJsonArgument,
normalizeMockNetworkAddPayload,
normalizeMockWebSocketAddPayload,
resolveWorktreeRoot,
readDaemonConfig,
shutdownDaemon,
Expand Down Expand Up @@ -428,6 +430,23 @@ describe('normalizeMockNetworkAddPayload', () => {
});
});

describe('normalizeMockWebSocketAddPayload', () => {
it('extracts mocks from a config object', () => {
expect(normalizeMockWebSocketAddPayload({ mocks: [1] })).toStrictEqual({
mocks: [1],
});
});

it('wraps an array as mocks', () => {
expect(normalizeMockWebSocketAddPayload([1])).toStrictEqual({ mocks: [1] });
});

it('wraps a plain object as a single mock', () => {
const mock = { url: 'wss://example.com/ws', rules: [] };
expect(normalizeMockWebSocketAddPayload(mock)).toStrictEqual({ mock });
});
});

describe('parseLaunchArgs', () => {
it('returns empty object for no args', () => {
expect(parseLaunchArgs([])).toStrictEqual({});
Expand Down Expand Up @@ -1877,6 +1896,85 @@ describe('routeCommand', () => {
);
});

it('routes mock-websocket add with a JSON mock', async () => {
const mock = {
url: 'wss://api.hyperliquid.xyz/ws',
rules: [{ id: 'test', match: { includes: 'subscribe' }, respond: {} }],
};
await routeCommand('mock-websocket', ['add', JSON.stringify(mock)], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/mock_websocket',
expect.objectContaining({
body: JSON.stringify({ action: 'add', mock }),
}),
);
});

it('routes mock-websocket add with a JSON config containing mocks array', async () => {
const mock = {
url: 'wss://api.hyperliquid.xyz/ws',
rules: [{ id: 'test', match: { includes: 'subscribe' }, respond: {} }],
};
await routeMockWebSocketCommand(
['add', JSON.stringify({ mocks: [mock] })],
3000,
);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/mock_websocket',
expect.objectContaining({
body: JSON.stringify({ action: 'add', mocks: [mock] }),
}),
);
});

it('routes mock-websocket clear', async () => {
await routeCommand('mock-websocket', ['clear'], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/mock_websocket',
expect.objectContaining({
body: JSON.stringify({ action: 'clear' }),
}),
);
});

it('routes mock-websocket list', async () => {
await routeCommand('mock-websocket', ['list'], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/mock_websocket',
expect.objectContaining({
body: JSON.stringify({ action: 'list' }),
}),
);
});

it('routes mock-websocket messages with limit', async () => {
await routeCommand('mock-websocket', ['messages', '--limit', '10'], 3000);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/tool/mock_websocket',
expect.objectContaining({
body: JSON.stringify({ action: 'messages', limit: 10 }),
}),
);
});

it('exits when mock-websocket add has no payload', async () => {
await expect(
routeCommand('mock-websocket', ['add'], 3000),
).rejects.toThrowError('process.exit');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Usage: mm mock-websocket add'),
);
});

it('exits when mock-websocket has an unknown action', async () => {
await expect(
routeCommand('mock-websocket', ['unknown'], 3000),
).rejects.toThrowError('process.exit');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Usage: mm mock-websocket'),
);
});

it('routes cdp with method and params', async () => {
await routeCommand(
'cdp',
Expand Down
84 changes: 84 additions & 0 deletions src/cli/mm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,10 @@ export async function routeCommand(
await routeMockNetworkCommand(args, port);
break;
}
case 'mock-websocket': {
await routeMockWebSocketCommand(args, port);
break;
}
case 'cdp': {
const cdpMethod = args[0];
if (!cdpMethod) {
Expand Down Expand Up @@ -745,6 +749,62 @@ export async function routeMockNetworkCommand(
process.exit(1);
}

/**
* Routes mock-websocket subcommands to the daemon.
*
* @param args - CLI arguments after `mock-websocket`.
* @param port - The daemon HTTP server port.
*/
export async function routeMockWebSocketCommand(
args: string[],
port: number,
): Promise<void> {
const action = args[0];

if (action === 'add') {
const rawMock = args[1];
if (!rawMock) {
process.stderr.write(
"Usage: mm mock-websocket add '<json-mock-definition>'\n",
);
process.exit(1);
}

const parsed = parseJsonArgument(rawMock, 'mock-websocket add');
await sendRequest(port, 'POST', '/tool/mock_websocket', {
action: 'add',
...normalizeMockWebSocketAddPayload(parsed),
});
return;
}

if (action === 'clear') {
await sendRequest(port, 'POST', '/tool/mock_websocket', {
action: 'clear',
});
return;
}

if (action === 'list') {
await sendRequest(port, 'POST', '/tool/mock_websocket', { action: 'list' });
return;
}

if (action === 'messages') {
const limit = parseIntFlag(args, '--limit');
await sendRequest(port, 'POST', '/tool/mock_websocket', {
action: 'messages',
...(limit === undefined ? {} : { limit }),
});
return;
}

process.stderr.write(
'Usage: mm mock-websocket <add|clear|list|messages> [options]\n',
);
process.exit(1);
}

/**
* Checks whether a fetch error is transient and worth retrying.
* Only network-level failures are retried — HTTP responses (even errors) are not.
Expand Down Expand Up @@ -1281,6 +1341,26 @@ export function normalizeMockNetworkAddPayload(
return { rule: payload };
}

/**
* Normalizes a mock-websocket add payload.
*
* @param payload - Parsed JSON payload.
* @returns A payload accepted by the mock_websocket tool.
*/
export function normalizeMockWebSocketAddPayload(
payload: unknown,
): { mock: unknown } | { mocks: unknown } {
if (typeof payload === 'object' && payload !== null && 'mocks' in payload) {
return { mocks: (payload as { mocks: unknown }).mocks };
}

if (Array.isArray(payload)) {
return { mocks: payload };
}

return { mock: payload };
}

/**
* Parses launch command arguments into a key-value object.
*
Expand Down Expand Up @@ -1436,6 +1516,10 @@ Advanced:
mm mock-network clear
mm mock-network list
mm mock-network requests [--limit <n>]
mm mock-websocket add '<json-mock-definition>'
mm mock-websocket clear
mm mock-websocket list
mm mock-websocket messages [--limit <n>]
mm cdp <method> [params-json] [--timeout <ms>]

Examples:
Expand Down
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './interaction.js';
export * from './knowledge.js';
export * from './launch.js';
export * from './mock-network.js';
export * from './mock-websocket.js';
export * from './navigation.js';
export * from './registry.js';
export * from './screenshot.js';
Expand Down
Loading
Loading