Skip to content

Commit 810fc4a

Browse files
authored
Merge pull request #17 from verisoft-ai/feat/multi-monitor
feat(display): add support for multi monitor testing
2 parents 1151851 + 1029aec commit 810fc4a

File tree

5 files changed

+271
-31
lines changed

5 files changed

+271
-31
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,45 @@ button | string | no | Mouse button: `left` (default), `middle`, `right`, `back`
594594

595595
\* Provide either startElementId or both startX and startY; and either endElementId or both endX and endY.
596596

597+
### windows: getMonitors
598+
599+
Returns information about all connected display monitors, including their screen coordinates in the [virtual screen](https://learn.microsoft.com/en-us/windows/win32/gdi/the-virtual-screen) coordinate space, working area, device name, and which monitor is the primary display.
600+
601+
This command takes no arguments.
602+
603+
#### Returns
604+
605+
An array of monitor objects, one per connected display:
606+
607+
Name | Type | Description
608+
--- | --- | ---
609+
index | number | Zero-based index of the monitor in the `AllScreens` array.
610+
deviceName | string | System device name, e.g. `\\.\DISPLAY1`.
611+
primary | boolean | `true` if this is the primary display.
612+
bounds | object | Full monitor rectangle: `{ x, y, width, height }` in virtual screen coordinates.
613+
workingArea | object | Usable area excluding taskbars and docked toolbars: `{ x, y, width, height }`.
614+
615+
#### Example
616+
617+
```javascript
618+
// WebdriverIO — move app window to the secondary monitor
619+
const monitors = await driver.executeScript('windows: getMonitors', []);
620+
const secondary = monitors.find(m => !m.primary);
621+
if (secondary) {
622+
await driver.setWindowRect(secondary.bounds.x, secondary.bounds.y, null, null);
623+
}
624+
```
625+
626+
```python
627+
# Python — click at the center of the secondary monitor
628+
monitors = driver.execute_script('windows: getMonitors', {})
629+
secondary = next((m for m in monitors if not m['primary']), None)
630+
if secondary:
631+
cx = secondary['bounds']['x'] + secondary['bounds']['width'] // 2
632+
cy = secondary['bounds']['y'] + secondary['bounds']['height'] // 2
633+
driver.execute_script('windows: click', {'x': cx, 'y': cy})
634+
```
635+
597636
## Development
598637

599638
it is recommended to use Matt Bierner's [Comment tagged templates](https://marketplace.visualstudio.com/items?itemName=bierner.comment-tagged-templates)

lib/commands/extension.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const EXTENSION_COMMANDS = Object.freeze({
6767
clickAndDrag: 'executeClickAndDrag',
6868
getDeviceTime: 'windowsGetDeviceTime',
6969
getWindowElement: 'getWindowElement',
70+
getMonitors: 'windowsGetMonitors',
7071
} as const);
7172

7273
const ContentType = Object.freeze({
@@ -892,3 +893,23 @@ export async function getWindowElement(this: NovaWindowsDriver): Promise<Element
892893
}
893894
return { [W3C_ELEMENT_KEY]: elementId };
894895
}
896+
897+
const GET_MONITORS_COMMAND = pwsh /* ps1 */ `
898+
Add-Type -AssemblyName System.Windows.Forms
899+
$index = 0
900+
$monitors = @([System.Windows.Forms.Screen]::AllScreens | ForEach-Object {
901+
[PSCustomObject]@{
902+
index = $index++
903+
deviceName = $_.DeviceName
904+
primary = $_.Primary
905+
bounds = @{ x = $_.Bounds.X; y = $_.Bounds.Y; width = $_.Bounds.Width; height = $_.Bounds.Height }
906+
workingArea = @{ x = $_.WorkingArea.X; y = $_.WorkingArea.Y; width = $_.WorkingArea.Width; height = $_.WorkingArea.Height }
907+
}
908+
})
909+
ConvertTo-Json -InputObject $monitors -Compress
910+
`;
911+
912+
export async function windowsGetMonitors(this: NovaWindowsDriver): Promise<object[]> {
913+
const result = await this.sendPowerShellCommand(GET_MONITORS_COMMAND);
914+
return JSON.parse(result.trim());
915+
}

lib/mcp/tools/window.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,21 @@ export function registerWindowTools(server: McpServer, session: AppiumSession):
167167
}
168168
}
169169
);
170+
171+
server.registerTool(
172+
'get_monitors',
173+
{
174+
description: 'List all connected monitors with their bounds, working area, device name, and whether each is the primary display.',
175+
annotations: { readOnlyHint: true },
176+
},
177+
async () => {
178+
try {
179+
const driver = session.getDriver();
180+
const monitors = await driver.executeScript('windows: getMonitors', []);
181+
return { content: [{ type: 'text' as const, text: JSON.stringify(monitors, null, 2) }] };
182+
} catch (err) {
183+
return { isError: true, content: [{ type: 'text' as const, text: formatError(err) }] };
184+
}
185+
}
186+
);
170187
}

lib/winapi/user32.ts

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,9 @@ function makeMouseMoveEvents(args: {
429429
wheel: boolean,
430430
/** Set to true if the event is a mouse move with relative coordinates. This argument is ignored for mouse wheel move. */
431431
relative?: boolean,
432-
/** Set to screen resolution [width, height] when the mouse move is absolute. */
433-
screenResolutionAndRefreshRate?: ReturnType<typeof getScreenResolutionAndRefreshRate>;
434432
}
435433
): MouseEvent[] {
436-
const { x, y, wheel, relative, screenResolutionAndRefreshRate} = args;
434+
const { x, y, wheel, relative } = args;
437435

438436
if (wheel) {
439437
const mouseEvents: MouseEvent[] = [];
@@ -457,18 +455,18 @@ function makeMouseMoveEvents(args: {
457455

458456
const mouseEvent: MouseEvent = makeEmptyMouseEvent();
459457

460-
if (!screenResolutionAndRefreshRate) {
461-
throw new errors.InvalidArgumentError('screenResolution parameter must be set for absolute mouse move.');
462-
}
463-
464-
const [screenWidth, screenHeight] = screenResolutionAndRefreshRate;
465-
466-
mouseEvent.u.mi.dx = relative ? Math.trunc(x) : Math.trunc((x * UINT16_MAX) / screenWidth);
467-
mouseEvent.u.mi.dy = relative ? Math.trunc(y) : Math.trunc((y * UINT16_MAX) / screenHeight);
468-
mouseEvent.u.mi.dwFlags = MouseEventFlags.MOUSEEVENTF_MOVE;
469-
470-
if (!relative) {
471-
mouseEvent.u.mi.dwFlags |= MouseEventFlags.MOUSEEVENTF_ABSOLUTE;
458+
if (relative) {
459+
mouseEvent.u.mi.dx = Math.trunc(x);
460+
mouseEvent.u.mi.dy = Math.trunc(y);
461+
mouseEvent.u.mi.dwFlags = MouseEventFlags.MOUSEEVENTF_MOVE;
462+
} else {
463+
const virt = getVirtualScreenBounds();
464+
mouseEvent.u.mi.dx = Math.trunc(((x - virt.left) * UINT16_MAX) / virt.width);
465+
mouseEvent.u.mi.dy = Math.trunc(((y - virt.top) * UINT16_MAX) / virt.height);
466+
mouseEvent.u.mi.dwFlags =
467+
MouseEventFlags.MOUSEEVENTF_MOVE |
468+
MouseEventFlags.MOUSEEVENTF_ABSOLUTE |
469+
MouseEventFlags.MOUSEEVENTF_VIRTUALDESK;
472470
}
473471

474472
return [mouseEvent];
@@ -651,8 +649,7 @@ function sendMouseButtonInput(button: number, down: boolean) {
651649
async function sendMouseMoveInput(args: { x: number, y: number, relative: boolean, duration: number, easingFunction?: string }): Promise<void> {
652650
const { duration } = args;
653651
let { x, y, easingFunction, relative } = args;
654-
const screenResolutionAndRefreshRate = getScreenResolutionAndRefreshRate();
655-
const [, , refreshRate] = screenResolutionAndRefreshRate;
652+
const refreshRate = getRefreshRate();
656653
const updateInterval = 1000 / refreshRate;
657654
const iterations = Math.max(Math.floor(duration / updateInterval), 1);
658655

@@ -695,14 +692,14 @@ async function sendMouseMoveInput(args: { x: number, y: number, relative: boolea
695692
const interpolatedX = cursorPosition.x + (x - cursorPosition.x) * easedProgress;
696693
const interpolatedY = cursorPosition.y + (y - cursorPosition.y) * easedProgress;
697694

698-
const events = makeMouseMoveEvents({ x: interpolatedX, y: interpolatedY, wheel: false, screenResolutionAndRefreshRate });
695+
const events = makeMouseMoveEvents({ x: interpolatedX, y: interpolatedY, wheel: false });
699696
const returnCode = SendInput(events.length, events, sizeof(INPUT));
700697

701698
assertSuccessSendInputReturnCode(returnCode);
702699
}, i * updateInterval);
703700
}
704701
} else {
705-
const events = makeMouseMoveEvents({ x, y, wheel: false, screenResolutionAndRefreshRate });
702+
const events = makeMouseMoveEvents({ x, y, wheel: false });
706703
const returnCode = SendInput(events.length, events, sizeof(INPUT));
707704

708705
assertSuccessSendInputReturnCode(returnCode);
@@ -736,33 +733,56 @@ function getResolutionScalingFactor(): number {
736733
return scalingFactor;
737734
}
738735

739-
function getScreenResolutionAndRefreshRate(): [number, number, number] {
740-
const width = GetSystemMetrics(SystemMetric.SM_CXSCREEN);
741-
const height = GetSystemMetrics(SystemMetric.SM_CYSCREEN);
742-
let refreshRate: number | null = null;
743-
736+
function getRefreshRate(): number {
744737
const buffer = Buffer.alloc(sizeof(DEVMODEA));
745738
EnumDisplaySettingsA(null, -1, buffer);
746-
const deviceMode = { dmDisplayFrequency: buffer.readUInt32LE(120) } as DeviceModeAnsi;
747-
refreshRate = deviceMode.dmDisplayFrequency;
739+
const refreshRate = (buffer.readUInt32LE(120) as DeviceModeAnsi['dmDisplayFrequency']);
748740

749-
const resolution = [width, height, refreshRate] satisfies ReturnType<typeof getScreenResolutionAndRefreshRate>;
741+
const nonMemoizedMethod = getRefreshRate;
742+
const currentTime = new Date().getTime();
750743

751-
const nonMemoizedMethod = getScreenResolutionAndRefreshRate;
744+
// @ts-expect-error memoizing the function to prevent repeated calls that might crash Node.js
745+
getRefreshRate = () => {
746+
if (new Date().getTime() - currentTime > 1000) {
747+
// @ts-expect-error reset memoization after 1 second
748+
getRefreshRate = nonMemoizedMethod;
749+
}
750+
return refreshRate;
751+
};
752+
753+
return refreshRate;
754+
}
755+
756+
function getScreenResolution(): [number, number] {
757+
const width = GetSystemMetrics(SystemMetric.SM_CXSCREEN);
758+
const height = GetSystemMetrics(SystemMetric.SM_CYSCREEN);
759+
760+
const resolution = [width, height] satisfies ReturnType<typeof getScreenResolution>;
761+
762+
const nonMemoizedMethod = getScreenResolution;
752763
const currentTime = new Date().getTime();
753764

754765
// @ts-expect-error memoizing the function to prevent repeated calls that might crash Node.js
755-
getScreenResolutionAndRefreshRate = () => {
766+
getScreenResolution = () => {
756767
if (new Date().getTime() - currentTime > 1000) {
757768
// @ts-expect-error reset memoization after 1 second
758-
getScreenResolutionAndRefreshRate = nonMemoizedMethod;
769+
getScreenResolution = nonMemoizedMethod;
759770
}
760771
return resolution;
761772
};
762773

763774
return resolution;
764775
}
765776

777+
export function getVirtualScreenBounds(): { left: number; top: number; width: number; height: number } {
778+
return {
779+
left: GetSystemMetrics(SystemMetric.SM_XVIRTUALSCREEN),
780+
top: GetSystemMetrics(SystemMetric.SM_YVIRTUALSCREEN),
781+
width: GetSystemMetrics(SystemMetric.SM_CXVIRTUALSCREEN),
782+
height: GetSystemMetrics(SystemMetric.SM_CYVIRTUALSCREEN),
783+
};
784+
}
785+
766786
export function keyDown(char: string, forceUnicode: boolean = false): void {
767787
sendKeyInput(char, true, forceUnicode);
768788
}
@@ -792,7 +812,7 @@ export function mouseUp(button: number = 0): void {
792812
}
793813

794814
export function getDisplayOrientation(): Orientation {
795-
const resolution = getScreenResolutionAndRefreshRate();
815+
const resolution = getScreenResolution();
796816
return resolution[0] > resolution[1] ? 'LANDSCAPE' : 'PORTRAIT';
797817
}
798818

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
2+
import type { Browser } from 'webdriverio';
3+
import {
4+
createCalculatorSession,
5+
quitSession,
6+
resetCalculator,
7+
} from './helpers/session.js';
8+
9+
describe('windows: getMonitors extension command', () => {
10+
let calc: Browser;
11+
12+
beforeAll(async () => {
13+
calc = await createCalculatorSession();
14+
});
15+
16+
afterAll(async () => {
17+
await quitSession(calc);
18+
});
19+
20+
beforeEach(async () => {
21+
await resetCalculator(calc);
22+
});
23+
24+
describe('getMonitors — response shape', () => {
25+
it('returns a non-empty array', async () => {
26+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
27+
expect(Array.isArray(monitors)).toBe(true);
28+
expect(monitors.length).toBeGreaterThanOrEqual(1);
29+
});
30+
31+
it('each monitor has required numeric index and non-empty deviceName', async () => {
32+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
33+
for (const monitor of monitors) {
34+
expect(typeof monitor.index).toBe('number');
35+
expect(typeof monitor.deviceName).toBe('string');
36+
expect(monitor.deviceName.length).toBeGreaterThan(0);
37+
}
38+
});
39+
40+
it('each monitor has a boolean primary field', async () => {
41+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
42+
for (const monitor of monitors) {
43+
expect(typeof monitor.primary).toBe('boolean');
44+
}
45+
});
46+
47+
it('exactly one monitor is marked as primary', async () => {
48+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
49+
const primaries = monitors.filter((m: any) => m.primary);
50+
expect(primaries).toHaveLength(1);
51+
});
52+
53+
it('each monitor has bounds with positive width and height', async () => {
54+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
55+
for (const monitor of monitors) {
56+
expect(typeof monitor.bounds.x).toBe('number');
57+
expect(typeof monitor.bounds.y).toBe('number');
58+
expect(monitor.bounds.width).toBeGreaterThan(0);
59+
expect(monitor.bounds.height).toBeGreaterThan(0);
60+
}
61+
});
62+
63+
it('each monitor has workingArea with positive width and height', async () => {
64+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
65+
for (const monitor of monitors) {
66+
expect(typeof monitor.workingArea.x).toBe('number');
67+
expect(typeof monitor.workingArea.y).toBe('number');
68+
expect(monitor.workingArea.width).toBeGreaterThan(0);
69+
expect(monitor.workingArea.height).toBeGreaterThan(0);
70+
}
71+
});
72+
73+
it('workingArea is contained within bounds for each monitor', async () => {
74+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
75+
for (const monitor of monitors) {
76+
expect(monitor.workingArea.x).toBeGreaterThanOrEqual(monitor.bounds.x);
77+
expect(monitor.workingArea.y).toBeGreaterThanOrEqual(monitor.bounds.y);
78+
expect(monitor.workingArea.width).toBeLessThanOrEqual(monitor.bounds.width);
79+
expect(monitor.workingArea.height).toBeLessThanOrEqual(monitor.bounds.height);
80+
}
81+
});
82+
83+
it('monitor indices are sequential starting from 0', async () => {
84+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
85+
const indices = monitors.map((m: any) => m.index).sort((a: number, b: number) => a - b);
86+
for (let i = 0; i < indices.length; i++) {
87+
expect(indices[i]).toBe(i);
88+
}
89+
});
90+
91+
it('primary monitor bounds origin is at the Windows virtual origin (0, 0)', async () => {
92+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
93+
const primary = monitors.find((m: any) => m.primary);
94+
expect(primary.bounds.x).toBe(0);
95+
expect(primary.bounds.y).toBe(0);
96+
});
97+
});
98+
99+
describe('virtual-screen absolute click regression', () => {
100+
it('clicking Calculator "9" button by absolute screen coordinates shows 9 in display', async () => {
101+
const btn = await calc.$('~num9Button');
102+
const loc = await btn.getLocation();
103+
const size = await btn.getSize();
104+
const windowRect = await calc.getWindowRect();
105+
106+
const x = Math.round(windowRect.x + loc.x + size.width / 2);
107+
const y = Math.round(windowRect.y + loc.y + size.height / 2);
108+
109+
await calc.executeScript('windows: click', [{ x, y }]);
110+
111+
const display = await calc.$('~CalculatorResults');
112+
expect(await display.getText()).toContain('9');
113+
});
114+
115+
it('clicking Calculator "5" button by absolute screen coordinates shows 5 in display', async () => {
116+
const btn = await calc.$('~num5Button');
117+
const loc = await btn.getLocation();
118+
const size = await btn.getSize();
119+
const windowRect = await calc.getWindowRect();
120+
121+
const x = Math.round(windowRect.x + loc.x + size.width / 2);
122+
const y = Math.round(windowRect.y + loc.y + size.height / 2);
123+
124+
await calc.executeScript('windows: click', [{ x, y }]);
125+
126+
const display = await calc.$('~CalculatorResults');
127+
expect(await display.getText()).toContain('5');
128+
});
129+
130+
it('absolute coordinates derived from getMonitors primary bounds contain the Calculator window', async () => {
131+
const monitors = await calc.executeScript('windows: getMonitors', []) as any[];
132+
const primary = monitors.find((m: any) => m.primary);
133+
const windowRect = await calc.getWindowRect();
134+
135+
// Calculator window should fall within primary monitor bounds
136+
// (it was launched without any monitor preference, so it opens on primary)
137+
expect(windowRect.x).toBeGreaterThanOrEqual(primary.bounds.x);
138+
expect(windowRect.y).toBeGreaterThanOrEqual(primary.bounds.y);
139+
expect(windowRect.x + windowRect.width).toBeLessThanOrEqual(primary.bounds.x + primary.bounds.width);
140+
expect(windowRect.y + windowRect.height).toBeLessThanOrEqual(primary.bounds.y + primary.bounds.height);
141+
});
142+
});
143+
});

0 commit comments

Comments
 (0)