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
4 changes: 4 additions & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
- **from_uid** (string) **(required)**: The uid of the element to [`drag`](#drag)
- **to_uid** (string) **(required)**: The uid of the element to drop into
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
- **to_fraction_x** (number) _(optional)_: Optional x fraction within the target element bounding rect. 0 is the left edge and 1 is the right edge. Cannot be combined with to_offset_x.
- **to_fraction_y** (number) _(optional)_: Optional y fraction within the target element bounding rect. 0 is the top edge and 1 is the bottom edge. Cannot be combined with to_offset_y.
- **to_offset_x** (number) _(optional)_: Optional x offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_x.
- **to_offset_y** (number) _(optional)_: Optional y offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_y.

---

Expand Down
28 changes: 28 additions & 0 deletions src/bin/chrome-devtools-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@ export const commands: Commands = {
description: 'The uid of the element to drop into',
required: true,
},
to_offset_x: {
name: 'to_offset_x',
type: 'number',
description:
'Optional x offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_x.',
required: false,
},
to_offset_y: {
name: 'to_offset_y',
type: 'number',
description:
'Optional y offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_y.',
required: false,
},
to_fraction_x: {
name: 'to_fraction_x',
type: 'number',
description:
'Optional x fraction within the target element bounding rect. 0 is the left edge and 1 is the right edge. Cannot be combined with to_offset_x.',
required: false,
},
to_fraction_y: {
name: 'to_fraction_y',
type: 'number',
description:
'Optional y fraction within the target element bounding rect. 0 is the top edge and 1 is the bottom edge. Cannot be combined with to_offset_y.',
required: false,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
Expand Down
16 changes: 16 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@
{
"name": "include_snapshot",
"argType": "boolean"
},
{
"name": "to_offset_x",
"argType": "number"
},
{
"name": "to_offset_y",
"argType": "number"
},
{
"name": "to_fraction_x",
"argType": "number"
},
{
"name": "to_fraction_y",
"argType": "number"
}
]
},
Expand Down
138 changes: 135 additions & 3 deletions src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,108 @@ const submitKeySchema = zod
'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
);

const dragOffsetXSchema = zod
.number()
.optional()
.describe(
'Optional x offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_x.',
);

const dragOffsetYSchema = zod
.number()
.optional()
.describe(
'Optional y offset in CSS pixels from the target element bounding rect top-left corner. Cannot be combined with to_fraction_y.',
);

const dragFractionXSchema = zod
.number()
.min(0)
.max(1)
.optional()
.describe(
'Optional x fraction within the target element bounding rect. 0 is the left edge and 1 is the right edge. Cannot be combined with to_offset_x.',
);

const dragFractionYSchema = zod
.number()
.min(0)
.max(1)
.optional()
.describe(
'Optional y fraction within the target element bounding rect. 0 is the top edge and 1 is the bottom edge. Cannot be combined with to_offset_y.',
);

const CUSTOM_DRAG_DELAY_MS = 150;

interface DragCustomDropParams {
to_offset_x?: number;
to_offset_y?: number;
to_fraction_x?: number;
to_fraction_y?: number;
}

function hasCustomDropPoint(params: DragCustomDropParams): boolean {
return (
params.to_offset_x !== undefined ||
params.to_offset_y !== undefined ||
params.to_fraction_x !== undefined ||
params.to_fraction_y !== undefined
);
}

function assertValidCustomDropPoint(params: DragCustomDropParams): void {
if (params.to_offset_x !== undefined && params.to_fraction_x !== undefined) {
throw new Error(
'Specify only one of to_offset_x or to_fraction_x for drag().',
);
}
if (params.to_offset_y !== undefined && params.to_fraction_y !== undefined) {
throw new Error(
'Specify only one of to_offset_y or to_fraction_y for drag().',
);
}
}

function resolveDropAxisCoordinate(
origin: number,
size: number,
offset?: number,
fraction?: number,
): number {
if (offset !== undefined) {
return origin + offset;
}
if (fraction !== undefined) {
return origin + size * fraction;
}
return origin + size / 2;
}

async function resolveCustomDropPoint(
handle: ElementHandle<Element>,
params: DragCustomDropParams,
): Promise<{x: number; y: number}> {
const box = await handle.boundingBox();
if (!box) {
throw new Error('Failed to compute the drag drop target bounding box.');
}
return {
x: resolveDropAxisCoordinate(
box.x,
box.width,
params.to_offset_x,
params.to_fraction_x,
),
y: resolveDropAxisCoordinate(
box.y,
box.height,
params.to_offset_y,
params.to_fraction_y,
),
};
}

function handleActionError(error: unknown, uid: string) {
logger('failed to act using a locator', error);
throw new Error(
Expand Down Expand Up @@ -378,6 +480,10 @@ export const drag = definePageTool({
schema: {
from_uid: zod.string().describe('The uid of the element to drag'),
to_uid: zod.string().describe('The uid of the element to drop into'),
to_offset_x: dragOffsetXSchema,
to_offset_y: dragOffsetYSchema,
to_fraction_x: dragFractionXSchema,
to_fraction_y: dragFractionYSchema,
includeSnapshot: includeSnapshotSchema,
},
blockedByDialog: true,
Expand All @@ -387,11 +493,37 @@ export const drag = definePageTool({
request.params.from_uid,
);
const toHandle = await request.page.getElementByUid(request.params.to_uid);
const customDropParams: DragCustomDropParams = {
to_offset_x: request.params.to_offset_x,
to_offset_y: request.params.to_offset_y,
to_fraction_x: request.params.to_fraction_x,
to_fraction_y: request.params.to_fraction_y,
};
try {
const result = await request.page.waitForEventsAfterAction(async () => {
await fromHandle.drag(toHandle);
await new Promise(resolve => setTimeout(resolve, 50));
await toHandle.drop(fromHandle);
if (!hasCustomDropPoint(customDropParams)) {
await fromHandle.drag(toHandle);
await new Promise(resolve => setTimeout(resolve, 50));
await toHandle.drop(fromHandle);
return;
}

assertValidCustomDropPoint(customDropParams);

await fromHandle.scrollIntoView();
await toHandle.scrollIntoView();

const targetPoint = await resolveCustomDropPoint(
toHandle,
customDropParams,
);
const mouse = request.page.pptrPage.mouse;

await fromHandle.hover();
await mouse.down();
await mouse.move(targetPoint.x, targetPoint.y);
await new Promise(resolve => setTimeout(resolve, CUSTOM_DRAG_DELAY_MS));
await mouse.up();
});
response.appendResponseLine(`Successfully dragged an element`);
response.attachWaitForResult(result);
Expand Down
60 changes: 60 additions & 0 deletions tests/tools/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,66 @@ describe('input', () => {
assert.ok(await page.$('text/dropped'));
});
});

it('drags onto a biased point inside the target bounding box', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`<div
role="button"
id="drag"
draggable="true"
>drag me</div
>
<div
id="drop"
aria-label="drop"
style="width: 120px; height: 120px; border: 1px solid black;"
></div>
<script>
drag.addEventListener('dragstart', event => {
event.dataTransfer.setData('text/plain', event.target.id);
});
drop.addEventListener('dragover', event => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
});
drop.addEventListener('drop', event => {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const relativeY = event.clientY - rect.top;
event.currentTarget.setAttribute(
'data-region',
relativeY < rect.height / 2 ? 'top' : 'bottom',
);
});
</script>`,
);
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
await drag.handler(
{
params: {
from_uid: '1_1',
to_uid: '1_2',
to_offset_y: 10,
},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
'Successfully dragged an element',
);
assert.strictEqual(
await page.$eval('#drop', el => el.getAttribute('data-region')),
'top',
);
});
});
});

describe('fill form', () => {
Expand Down
Loading