mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 10:18:15 +00:00
1289 lines
37 KiB
TypeScript
1289 lines
37 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
|
|
import type { MockPageState } from './browser-mcp-test-harness';
|
|
|
|
describe('ccs-browser MCP server - advanced interactions', () => {
|
|
it('drags local files onto normal, frame, and shadow dropzones', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
|
const invoicePath = join(tempDir, 'invoice.pdf');
|
|
const receiptPath = join(tempDir, 'receipt.png');
|
|
writeFileSync(invoicePath, 'invoice');
|
|
writeFileSync(receiptPath, 'receipt');
|
|
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Uploads',
|
|
currentUrl: 'https://example.com/uploads',
|
|
dropzones: {
|
|
'#dropzone': { accepted: true },
|
|
'#cancel-dropzone': { acceptedByCancel: true },
|
|
},
|
|
frames: [
|
|
{
|
|
selector: '#upload-frame',
|
|
dropzones: {
|
|
'#frame-dropzone': { accepted: true },
|
|
},
|
|
},
|
|
],
|
|
shadowRoots: [
|
|
{
|
|
hostSelector: 'upload-panel',
|
|
dropzones: {
|
|
'#shadow-dropzone': { accepted: true },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 901,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 902,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: {
|
|
selector: '#frame-dropzone',
|
|
files: [invoicePath],
|
|
frameSelector: '#upload-frame',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 903,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: {
|
|
selector: '#shadow-dropzone',
|
|
files: [receiptPath],
|
|
pierceShadow: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 904,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: {
|
|
selector: '#cancel-dropzone',
|
|
files: [invoicePath],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped');
|
|
expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([
|
|
'dragenter',
|
|
'dragover',
|
|
'drop',
|
|
]);
|
|
expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([
|
|
{ name: 'invoice.pdf', size: 7, type: 'application/pdf' },
|
|
{ name: 'receipt.png', size: 7, type: 'image/png' },
|
|
]);
|
|
expect(
|
|
getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles
|
|
).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]);
|
|
expect(
|
|
getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles
|
|
).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]);
|
|
expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
|
|
'status: files-dropped'
|
|
);
|
|
expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([
|
|
{ name: 'invoice.pdf', size: 7, type: 'application/pdf' },
|
|
]);
|
|
});
|
|
|
|
it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
|
const okPath = join(tempDir, 'ok.txt');
|
|
const missingPath = join(tempDir, 'missing.txt');
|
|
writeFileSync(okPath, 'ok');
|
|
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Uploads',
|
|
currentUrl: 'https://example.com/uploads',
|
|
dropzones: {
|
|
'#reject-dropzone': { accepted: false },
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 904,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: { selector: '#reject-dropzone', files: [missingPath] },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 905,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 906,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: { selector: '#missing-dropzone', files: [okPath] },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 907,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_files',
|
|
arguments: { selector: '#reject-dropzone', files: [okPath] },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist');
|
|
expect(getResponseText(responses.find((message) => message.id === 905))).toContain(
|
|
'pageIndex and pageId cannot be used together'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 906))).toContain(
|
|
'element not found for selector: #missing-dropzone'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 907))).toContain(
|
|
'drop target rejected files'
|
|
);
|
|
});
|
|
|
|
it('drags an element to another element and to explicit coordinates', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Drag Page',
|
|
currentUrl: 'https://example.com/drag',
|
|
query: {
|
|
'#card-a': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 20,
|
|
y: 40,
|
|
width: 100,
|
|
height: 60,
|
|
top: 40,
|
|
right: 120,
|
|
bottom: 100,
|
|
left: 20,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
'#lane-b': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 240,
|
|
y: 60,
|
|
width: 120,
|
|
height: 80,
|
|
top: 60,
|
|
right: 360,
|
|
bottom: 140,
|
|
left: 240,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 'page-2',
|
|
title: 'Second Drag Page',
|
|
currentUrl: 'https://example.com/drag-2',
|
|
query: {
|
|
'#card-a': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 10,
|
|
y: 20,
|
|
width: 40,
|
|
height: 20,
|
|
top: 20,
|
|
right: 50,
|
|
bottom: 40,
|
|
left: 10,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
'#lane-b': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 110,
|
|
y: 80,
|
|
width: 60,
|
|
height: 30,
|
|
top: 80,
|
|
right: 170,
|
|
bottom: 110,
|
|
left: 110,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 908,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 909,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 916,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 },
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged');
|
|
expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b');
|
|
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged');
|
|
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420');
|
|
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180');
|
|
expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1');
|
|
expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b');
|
|
expect(pages[0]!.drag?.recordedActions).toEqual([
|
|
{ type: 'mouseMoved', x: 70, y: 70, button: 'none' },
|
|
{ type: 'mousePressed', x: 70, y: 70, button: 'left' },
|
|
{ type: 'mouseMoved', x: 147, y: 80, button: 'left' },
|
|
{ type: 'mouseMoved', x: 223, y: 90, button: 'left' },
|
|
{ type: 'mouseMoved', x: 300, y: 100, button: 'left' },
|
|
{ type: 'mouseReleased', x: 300, y: 100, button: 'left' },
|
|
{ type: 'mouseMoved', x: 70, y: 70, button: 'none' },
|
|
{ type: 'mousePressed', x: 70, y: 70, button: 'left' },
|
|
{ type: 'mouseMoved', x: 245, y: 125, button: 'left' },
|
|
{ type: 'mouseMoved', x: 420, y: 180, button: 'left' },
|
|
{ type: 'mouseReleased', x: 420, y: 180, button: 'left' },
|
|
]);
|
|
expect(pages[1]!.drag?.recordedActions).toEqual([
|
|
{ type: 'mouseMoved', x: 30, y: 30, button: 'none' },
|
|
{ type: 'mousePressed', x: 30, y: 30, button: 'left' },
|
|
{ type: 'mouseMoved', x: 140, y: 95, button: 'left' },
|
|
{ type: 'mouseReleased', x: 140, y: 95, button: 'left' },
|
|
]);
|
|
});
|
|
|
|
it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Drag Failures',
|
|
currentUrl: 'https://example.com/drag',
|
|
query: {
|
|
'#card-a': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 20,
|
|
y: 40,
|
|
width: 100,
|
|
height: 60,
|
|
top: 40,
|
|
right: 120,
|
|
bottom: 100,
|
|
left: 20,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 910,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 911,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { selector: '#missing-source', targetX: 400, targetY: 200 },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 912,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { selector: '#card-a', targetSelector: '#missing-target' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 917,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_drag_element',
|
|
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 910))).toContain(
|
|
'targetSelector and targetX/targetY cannot be used together'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 911))).toContain(
|
|
'source element not found'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 912))).toContain(
|
|
'target element not found'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 917))).toContain(
|
|
'pageIndex and pageId cannot be used together'
|
|
);
|
|
});
|
|
|
|
it('runs minimal pointer sequences with browser_pointer_action', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Pointer Page',
|
|
currentUrl: 'https://example.com/pointer',
|
|
query: {
|
|
'#handle': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 30,
|
|
y: 50,
|
|
width: 40,
|
|
height: 20,
|
|
top: 50,
|
|
right: 70,
|
|
bottom: 70,
|
|
left: 30,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 'page-2',
|
|
title: 'Second Pointer Page',
|
|
currentUrl: 'https://example.com/pointer-2',
|
|
query: {
|
|
'#handle': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 100,
|
|
y: 200,
|
|
width: 20,
|
|
height: 20,
|
|
top: 200,
|
|
right: 120,
|
|
bottom: 220,
|
|
left: 100,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 913,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
actions: [
|
|
{ type: 'move', selector: '#handle' },
|
|
{ type: 'down' },
|
|
{ type: 'move', x: 220, y: 160 },
|
|
{ type: 'pause', durationMs: 5 },
|
|
{ type: 'up' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 918,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
pageId: 'page-2',
|
|
actions: [
|
|
{ type: 'move', selector: '#handle' },
|
|
{ type: 'down' },
|
|
{ type: 'up' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed');
|
|
expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1');
|
|
expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed');
|
|
expect(pages[0]!.drag?.recordedActions).toEqual([
|
|
{ type: 'mouseMoved', x: 50, y: 60, button: 'none' },
|
|
{ type: 'mousePressed', x: 50, y: 60, button: 'left' },
|
|
{ type: 'mouseMoved', x: 220, y: 160, button: 'left' },
|
|
{ type: 'mouseReleased', x: 220, y: 160, button: 'left' },
|
|
]);
|
|
expect(pages[1]!.drag?.recordedActions).toEqual([
|
|
{ type: 'mouseMoved', x: 110, y: 210, button: 'none' },
|
|
{ type: 'mousePressed', x: 110, y: 210, button: 'left' },
|
|
{ type: 'mouseReleased', x: 110, y: 210, button: 'left' },
|
|
]);
|
|
});
|
|
|
|
it('rejects invalid browser_pointer_action sequences and page conflicts', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Pointer Failures',
|
|
currentUrl: 'https://example.com/pointer',
|
|
query: {
|
|
'#handle': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 30,
|
|
y: 50,
|
|
width: 40,
|
|
height: 20,
|
|
top: 50,
|
|
right: 70,
|
|
bottom: 70,
|
|
left: 30,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 914,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
actions: [{ type: 'up' }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 915,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
actions: [{ type: 'move', selector: '#missing-handle' }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 919,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
pageIndex: 0,
|
|
pageId: 'page-1',
|
|
actions: [{ type: 'move', x: 10, y: 10 }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 920,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
actions: [
|
|
{ type: 'move', selector: '#handle' },
|
|
{ type: 'down', button: 'left' },
|
|
{ type: 'up', button: 'right' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error');
|
|
expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable');
|
|
expect(getResponseText(responses.find((message) => message.id === 919))).toContain(
|
|
'pageIndex and pageId cannot be used together'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 920))).toContain(
|
|
'pointer state error'
|
|
);
|
|
});
|
|
|
|
it('rejects mutating browser tools when the selected page becomes stale', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-'));
|
|
const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs');
|
|
writeFileSync(
|
|
patchedServerPath,
|
|
readFileSync(bundledServerPath, 'utf8').replace(
|
|
"let selectedPageId = '';",
|
|
"let selectedPageId = 'page-2';"
|
|
)
|
|
);
|
|
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Primary Page',
|
|
currentUrl: 'https://example.com/primary',
|
|
query: {
|
|
'#handle': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 30,
|
|
y: 50,
|
|
width: 40,
|
|
height: 20,
|
|
top: 50,
|
|
right: 70,
|
|
bottom: 70,
|
|
left: 30,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 923,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_pointer_action',
|
|
arguments: {
|
|
actions: [{ type: 'move', selector: '#handle' }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
{ serverPath: patchedServerPath }
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 923))).toContain(
|
|
'Selected page is no longer available; specify pageIndex or pageId explicitly.'
|
|
);
|
|
});
|
|
|
|
it('waits for a matching navigation event with browser_wait_for_event', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Event Page',
|
|
currentUrl: 'https://example.com/',
|
|
events: {
|
|
navigations: [{ url: 'https://example.com/checkout' }],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 69,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_wait_for_event',
|
|
arguments: {
|
|
timeoutMs: 1000,
|
|
event: { kind: 'navigation', urlIncludes: '/checkout' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
|
|
'status: observed'
|
|
);
|
|
});
|
|
|
|
it('ignores child-frame navigations when waiting for a page navigation event', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Event Page',
|
|
currentUrl: 'https://example.com/',
|
|
events: {
|
|
navigations: [
|
|
{ url: 'https://example.com/embedded-checkout', parentId: 'frame-1' },
|
|
{ url: 'https://example.com/checkout' },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 58,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_wait_for_event',
|
|
arguments: {
|
|
timeoutMs: 1000,
|
|
event: { kind: 'navigation', urlIncludes: '/checkout' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 58));
|
|
expect(text).toContain('status: observed');
|
|
expect(text).toContain('"url":"https://example.com/checkout"');
|
|
expect(text).not.toContain('embedded-checkout');
|
|
});
|
|
|
|
it('filters download events by the selected page frame when pageIndex is provided', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'First Page',
|
|
currentUrl: 'https://example.com/one',
|
|
events: {
|
|
downloads: [
|
|
{
|
|
url: 'https://example.com/first-report.csv',
|
|
suggestedFilename: 'first-report.csv',
|
|
frameId: 'frame-page-1',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'page-2',
|
|
title: 'Second Page',
|
|
currentUrl: 'https://example.com/two',
|
|
events: {
|
|
downloads: [
|
|
{
|
|
url: 'https://example.com/second-report.csv',
|
|
suggestedFilename: 'second-report.csv',
|
|
frameId: 'frame-page-2',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 59,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_wait_for_event',
|
|
arguments: {
|
|
pageIndex: 1,
|
|
timeoutMs: 1000,
|
|
event: { kind: 'download', suggestedFilenameIncludes: 'second-report' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 59));
|
|
expect(text).toContain('status: observed');
|
|
expect(text).toContain('"suggestedFilename":"second-report.csv"');
|
|
expect(text).not.toContain('first-report.csv');
|
|
});
|
|
|
|
it('matches download events from child frames within the selected page', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Framed Page',
|
|
currentUrl: 'https://example.com/frame-host',
|
|
frameTree: {
|
|
frame: { id: 'frame-page-1' },
|
|
childFrames: [{ frame: { id: 'frame-page-1-child' } }],
|
|
},
|
|
events: {
|
|
downloads: [
|
|
{
|
|
url: 'https://example.com/embedded-report.csv',
|
|
suggestedFilename: 'embedded-report.csv',
|
|
frameId: 'frame-page-1-child',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'page-2',
|
|
title: 'Other Page',
|
|
currentUrl: 'https://example.com/other',
|
|
events: {
|
|
downloads: [
|
|
{
|
|
url: 'https://example.com/other-report.csv',
|
|
suggestedFilename: 'other-report.csv',
|
|
frameId: 'frame-page-2',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 60,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_wait_for_event',
|
|
arguments: {
|
|
pageIndex: 0,
|
|
timeoutMs: 1000,
|
|
event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 60));
|
|
expect(text).toContain('status: observed');
|
|
expect(text).toContain('"suggestedFilename":"embedded-report.csv"');
|
|
expect(text).not.toContain('other-report.csv');
|
|
});
|
|
|
|
it('refreshes frame ids when a download comes from a newly attached child frame', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Dynamic Frame Page',
|
|
currentUrl: 'https://example.com/dynamic',
|
|
frameTreeSequence: [
|
|
{ frame: { id: 'frame-page-1' } },
|
|
{
|
|
frame: { id: 'frame-page-1' },
|
|
childFrames: [{ frame: { id: 'frame-page-1-late-child' } }],
|
|
},
|
|
],
|
|
events: {
|
|
downloads: [
|
|
{
|
|
url: 'https://example.com/late-report.csv',
|
|
suggestedFilename: 'late-report.csv',
|
|
frameId: 'frame-page-1-late-child',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 61,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_wait_for_event',
|
|
arguments: {
|
|
pageIndex: 0,
|
|
timeoutMs: 1000,
|
|
event: { kind: 'download', suggestedFilenameIncludes: 'late-report' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 61));
|
|
expect(text).toContain('status: observed');
|
|
expect(text).toContain('"suggestedFilename":"late-report.csv"');
|
|
});
|
|
|
|
it('captures element screenshots using the post-scroll visible clip and reports failures', async () => {
|
|
const screenshotPlan: MockPageState['screenshot'] = {
|
|
data: 'ZWxlbWVudC1zaG90',
|
|
requireScrolledMeasurement: true,
|
|
expectedClip: {
|
|
x: 0,
|
|
y: 12,
|
|
width: 50,
|
|
height: 28,
|
|
scale: 1,
|
|
},
|
|
};
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Element Screenshot Page',
|
|
currentUrl: 'https://example.com/',
|
|
query: {
|
|
'.edit-button': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'Edit',
|
|
textContent: 'Edit',
|
|
rect: {
|
|
x: -14,
|
|
y: 12,
|
|
width: 64,
|
|
height: 28,
|
|
top: 12,
|
|
right: 50,
|
|
bottom: 40,
|
|
left: -14,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
'.zero-sized': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 10,
|
|
y: 10,
|
|
width: 0,
|
|
height: 0,
|
|
top: 10,
|
|
right: 10,
|
|
bottom: 10,
|
|
left: 10,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
screenshot: screenshotPlan,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_element_screenshot',
|
|
arguments: { selector: '.edit-button' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 3,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_element_screenshot',
|
|
arguments: { selector: '.missing-button' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 4,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_element_screenshot',
|
|
arguments: { selector: '.zero-sized' },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 2));
|
|
expect(text).toContain('selector: .edit-button');
|
|
expect(text).toContain('format: png');
|
|
expect(text).toContain('data: ZWxlbWVudC1zaG90');
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 3))).toContain(
|
|
'Browser MCP failed: element not found for selector: .missing-button'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 4))).toContain(
|
|
'Browser MCP failed: element has empty bounds for selector: .zero-sized'
|
|
);
|
|
|
|
const emptyPayloadResponses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Element Screenshot Empty Payload Page',
|
|
currentUrl: 'https://example.com/',
|
|
query: {
|
|
'.edit-button': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 220,
|
|
y: 80,
|
|
width: 64,
|
|
height: 28,
|
|
top: 80,
|
|
right: 284,
|
|
bottom: 108,
|
|
left: 220,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
screenshot: { data: '' },
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_element_screenshot',
|
|
arguments: { selector: '.edit-button' },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain(
|
|
'Browser MCP failed: screenshot capture failed'
|
|
);
|
|
|
|
const iframeScreenshotResponses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Iframe Screenshot Page',
|
|
currentUrl: 'https://example.com/',
|
|
frames: [
|
|
{
|
|
selector: '#details-frame',
|
|
query: {
|
|
'#details-frame': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 40,
|
|
y: 100,
|
|
width: 300,
|
|
height: 200,
|
|
top: 100,
|
|
right: 340,
|
|
bottom: 300,
|
|
left: 40,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
'.save-button': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 10,
|
|
y: 20,
|
|
width: 60,
|
|
height: 24,
|
|
top: 20,
|
|
right: 70,
|
|
bottom: 44,
|
|
left: 10,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
screenshot: {
|
|
data: 'aWZyYW1lLXNob3Q=',
|
|
requireScrolledMeasurement: true,
|
|
expectedClip: {
|
|
x: 50,
|
|
y: 120,
|
|
width: 60,
|
|
height: 24,
|
|
scale: 1,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 5,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_element_screenshot',
|
|
arguments: { selector: '.save-button', frameSelector: '#details-frame' },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(
|
|
getResponseText(iframeScreenshotResponses.find((message) => message.id === 5))
|
|
).toContain('data: aWZyYW1lLXNob3Q=');
|
|
});
|
|
|
|
it('captures screenshots and reports empty payload failures', async () => {
|
|
const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' };
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Example Page',
|
|
currentUrl: 'https://example.com/',
|
|
screenshot: screenshotPlan,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_screenshot',
|
|
arguments: { fullPage: true },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 2));
|
|
expect(text).toContain('pageIndex: 0');
|
|
expect(text).toContain('format: png');
|
|
expect(text).toContain('fullPage: true');
|
|
expect(text).toContain('data: c2NyZWVuc2hvdA==');
|
|
expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true);
|
|
|
|
const errorResponses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Example Page',
|
|
currentUrl: 'https://example.com/',
|
|
screenshot: { data: '' },
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_take_screenshot',
|
|
arguments: {},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const errorResponse = errorResponses.find((message) => message.id === 2);
|
|
expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true);
|
|
expect(getResponseText(errorResponse)).toContain(
|
|
'Browser MCP failed: screenshot capture failed'
|
|
);
|
|
});
|
|
|
|
it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Example Page',
|
|
currentUrl: 'https://example.com/',
|
|
type: {
|
|
'input[name="email"]': {
|
|
kind: 'input',
|
|
inputType: 'email',
|
|
value: 'old@example.com',
|
|
expectedValueWhenAppend: 'old@example.comhi@example.com',
|
|
requireFocus: true,
|
|
},
|
|
'#notes': {
|
|
kind: 'textarea',
|
|
value: 'old note',
|
|
expectedValueWhenClearFirst: '',
|
|
requireFocus: true,
|
|
},
|
|
'#editor': {
|
|
kind: 'contenteditable',
|
|
value: 'old content',
|
|
expectedValueWhenAppend: 'old contentrich text',
|
|
requireFocus: true,
|
|
},
|
|
'#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' },
|
|
'#plain': { kind: 'noneditable', value: 'plain' },
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_type',
|
|
arguments: { selector: 'input[name="email"]', text: 'hi@example.com' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 3,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_type',
|
|
arguments: { selector: '#notes', text: '', clearFirst: true },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 4,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_type',
|
|
arguments: { selector: '#editor', text: 'rich text' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 5,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_type',
|
|
arguments: { selector: '#color', text: 'ignored' },
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 6,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_type',
|
|
arguments: { selector: '#plain', text: 'ignored' },
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
const typed = getResponseText(responses.find((message) => message.id === 2));
|
|
expect(typed).toContain('status: typed');
|
|
expect(typed).toContain('selector: input[name="email"]');
|
|
expect(typed).toContain('typedLength: 29');
|
|
|
|
const clearFirst = getResponseText(responses.find((message) => message.id === 3));
|
|
expect(clearFirst).toContain('status: typed');
|
|
expect(clearFirst).toContain('selector: #notes');
|
|
expect(clearFirst).toContain('typedLength: 0');
|
|
|
|
const contenteditable = getResponseText(responses.find((message) => message.id === 4));
|
|
expect(contenteditable).toContain('status: typed');
|
|
expect(contenteditable).toContain('selector: #editor');
|
|
expect(contenteditable).toContain('typedLength: 20');
|
|
|
|
const unsupported = responses.find((message) => message.id === 5);
|
|
expect((unsupported?.result as { isError?: boolean }).isError).toBe(true);
|
|
expect(getResponseText(unsupported)).toContain(
|
|
'element is not text-editable for selector: #color'
|
|
);
|
|
|
|
const nonEditable = responses.find((message) => message.id === 6);
|
|
expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true);
|
|
expect(getResponseText(nonEditable)).toContain(
|
|
'element is not text-editable for selector: #plain'
|
|
);
|
|
});
|
|
});
|