diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 16f707f2..3b1b81e7 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -57,12 +57,15 @@ Phase 5 capability details: - existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page - selected page state is session-local MCP runtime state and is not persisted across runtime restarts -Phase 6A capability details: +Phase 6 capability details: - interception rules are session-local and bind to the concrete page selected when the rule is created -- Phase 6A supports minimal request matching by `urlIncludes` and `method` +- Phase 6 supports minimal request matching by `urlIncludes` and `method` - Phase 6A actions are limited to `continue` and `fail` +- Phase 6B adds `fulfill` mock responses on top of the existing session-local interception model +- fulfill rules can return a custom status code, optional headers, and a UTF-8 response body - `browser_list_requests` returns recent request summaries, not full bodies +- response bodies are applied only to the matched request and are not persisted beyond the current MCP session Minimal multi-tab workflow examples: @@ -88,9 +91,12 @@ Minimal multi-tab workflow examples: { "name": "browser_add_intercept_rule", "arguments": { - "urlIncludes": "/api", + "urlIncludes": "/api/mock/users", "method": "GET", - "action": "fail" + "action": "fulfill", + "statusCode": 200, + "contentType": "application/json", + "body": "{\"users\":[1]}" } } ``` @@ -107,7 +113,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, response mocking, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, richer request matching, and download acceptance controls are still out of scope Example event wait: diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 2f125942..bffe461d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -2199,6 +2199,155 @@ describe('ccs-browser MCP server', () => { expect(addText).toContain('statusCode: 204'); }); + it('rejects fulfill rules when statusCode is out of range', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 968, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + statusCode: 99, + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 968); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); + }); + + it('rejects fulfill rules when headers is not an array', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 969, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + headers: 'x', + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 969); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headers must be an array'); + }); + + it('rejects fulfill rules when a header entry is missing name', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 970, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + headers: [{ value: 'x' }], + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 970); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headers.name is required'); + }); + + it('rejects fulfill rules when body is not a string', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 971, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + body: 123, + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 971); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: body must be a string'); + }); + + it('removes fulfill rules and request summaries after the bound page is closed', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Mocked', + currentUrl: 'https://example.com/mocked', + intercept: { + pausedRequests: [ + { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, + { + jsonrpc: '2.0', + id: 973, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock/close', + action: 'fulfill', + statusCode: 200, + body: 'done', + }, + }, + }, + { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, + { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, + { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + ], + { + responseTimeoutMs: 12000, + } + ); + + expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); + expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); + expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); + }); + it('returns only the requested number of recent requests', async () => { const responses = await runMcpRequests( [