Skip to content

Commit b3b0fd8

Browse files
committed
Extra W3C commands implementation with unit and e2e tests:
getTitle, back, forward, setWindowsRect, getElementsScreenshot
1 parent 803203e commit b3b0fd8

File tree

6 files changed

+519
-1
lines changed

6 files changed

+519
-1
lines changed

lib/commands/app.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ import { sleep } from '../util';
1717
import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver';
1818
import {
1919
getWindowAllHandlesForProcessIds,
20+
keyDown,
21+
keyUp,
2022
trySetForegroundWindow,
2123
} from '../winapi/user32';
24+
import { Key } from '../enums';
2225

2326
const GET_PAGE_SOURCE_COMMAND = pwsh$ /* ps1 */ `
2427
$el = ${0}
@@ -215,6 +218,68 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin
215218
throw new errors.UnknownError('Failed to locate window of the app.');
216219
}
217220

221+
export async function back(this: NovaWindowsDriver): Promise<void> {
222+
const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim();
223+
if (!elementId) {
224+
throw new errors.NoSuchWindowError('No active window found for this session.');
225+
}
226+
keyDown(Key.ALT);
227+
keyDown(Key.LEFT);
228+
keyUp(Key.LEFT);
229+
keyUp(Key.ALT);
230+
}
231+
232+
export async function forward(this: NovaWindowsDriver): Promise<void> {
233+
const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim();
234+
if (!elementId) {
235+
throw new errors.NoSuchWindowError('No active window found for this session.');
236+
}
237+
keyDown(Key.ALT);
238+
keyDown(Key.RIGHT);
239+
keyUp(Key.RIGHT);
240+
keyUp(Key.ALT);
241+
}
242+
243+
export async function title(this: NovaWindowsDriver): Promise<string> {
244+
const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim();
245+
if (!elementId) {
246+
throw new errors.NoSuchWindowError('No active window found for this session.');
247+
}
248+
return await this.sendPowerShellCommand(
249+
AutomationElement.automationRoot.buildGetPropertyCommand(Property.NAME)
250+
);
251+
}
252+
253+
export async function setWindowRect(
254+
this: NovaWindowsDriver,
255+
x: number | null,
256+
y: number | null,
257+
width: number | null,
258+
height: number | null
259+
): Promise<Rect> {
260+
if (width !== null && width < 0) {
261+
throw new errors.InvalidArgumentError('width must be a non-negative integer.');
262+
}
263+
if (height !== null && height < 0) {
264+
throw new errors.InvalidArgumentError('height must be a non-negative integer.');
265+
}
266+
267+
const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim();
268+
if (!elementId) {
269+
throw new errors.NoSuchWindowError('No active window found for this session.');
270+
}
271+
272+
const el = new FoundAutomationElement(elementId);
273+
if (x !== null && y !== null) {
274+
await this.sendPowerShellCommand(el.buildMoveCommand(x, y));
275+
}
276+
if (width !== null && height !== null) {
277+
await this.sendPowerShellCommand(el.buildResizeCommand(width, height));
278+
}
279+
280+
return await this.getWindowRect();
281+
}
282+
218283
export async function attachToApplicationWindow(this: NovaWindowsDriver, processIds: number[]): Promise<void> {
219284
const nativeWindowHandles = getWindowAllHandlesForProcessIds(processIds);
220285
this.log.debug(`Detected the following native window handles for the given process IDs: ${nativeWindowHandles.map((handle) => `0x${handle.toString(16).padStart(8, '0')}`).join(', ')}`);

lib/commands/element.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
PSControlType,
1313
PSString,
1414
TreeScope,
15+
pwsh$,
1516
} from '../powershell';
16-
import { W3C_ELEMENT_KEY } from '@appium/base-driver';
17+
import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver';
1718
import { mouseDown, mouseMoveAbsolute, mouseUp } from '../winapi/user32';
1819
import { Key } from '../enums';
1920
import { sleep } from '../util';
@@ -239,4 +240,27 @@ export async function click(this: NovaWindowsDriver, elementId: string): Promise
239240
if (this.caps.delayAfterClick) {
240241
await sleep(this.caps.delayAfterClick ?? 0);
241242
}
243+
}
244+
245+
const GET_ELEMENT_SCREENSHOT_COMMAND = pwsh$ /* ps1 */ `
246+
$element = ${0}
247+
$rect = $element.Current.BoundingRectangle
248+
$bitmap = New-Object Drawing.Bitmap([int32]$rect.Width, [int32]$rect.Height)
249+
$graphics = [Drawing.Graphics]::FromImage($bitmap)
250+
$graphics.CopyFromScreen([int32]$rect.Left, [int32]$rect.Top, 0, 0, $bitmap.Size)
251+
$graphics.Dispose()
252+
$stream = New-Object IO.MemoryStream
253+
$bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png)
254+
$bitmap.Dispose()
255+
[Convert]::ToBase64String($stream.ToArray())
256+
`;
257+
258+
export async function getElementScreenshot(this: NovaWindowsDriver, elementId: string): Promise<string> {
259+
const rootId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim();
260+
if (!rootId) {
261+
throw new errors.NoSuchWindowError('No active window found for this session.');
262+
}
263+
return await this.sendPowerShellCommand(
264+
GET_ELEMENT_SCREENSHOT_COMMAND.format(new FoundAutomationElement(elementId))
265+
);
242266
}

lib/powershell/elements.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ const MAXIMIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]:
348348
const MINIMIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).SetWindowVisualState([WindowVisualState]::Minimized)`;
349349
const RESTORE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).SetWindowVisualState([WindowVisualState]::Normal)`;
350350
const CLOSE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).Close()`;
351+
const MOVE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([TransformPattern]::Pattern).Move(${1}, ${2})`;
352+
const RESIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([TransformPattern]::Pattern).Resize(${1}, ${2})`;
351353

352354
export const TreeScope = Object.freeze({
353355
ANCESTORS_OR_SELF: 'ancestors-or-self',
@@ -578,6 +580,14 @@ export class FoundAutomationElement extends AutomationElement {
578580
return CLOSE_WINDOW.format(this);
579581
}
580582

583+
buildMoveCommand(x: number, y: number): string {
584+
return MOVE_WINDOW.format(this, x, y);
585+
}
586+
587+
buildResizeCommand(width: number, height: number): string {
588+
return RESIZE_WINDOW.format(this, width, height);
589+
}
590+
581591
override buildCommand(): string {
582592
return this.toString();
583593
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Unit tests for lib/commands/app.ts: back, forward, getTitle, setWindowRect
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import { back, forward, title, setWindowRect } from '../../../lib/commands/app';
6+
import { createMockDriver } from '../../fixtures/driver';
7+
import { Key } from '../../../lib/enums';
8+
9+
vi.mock('../../../lib/winapi/user32', () => ({
10+
getWindowAllHandlesForProcessIds: vi.fn().mockReturnValue([]),
11+
trySetForegroundWindow: vi.fn().mockReturnValue(true),
12+
keyDown: vi.fn(),
13+
keyUp: vi.fn(),
14+
}));
15+
16+
const ELEMENT_ID = '1.2.3.4.5';
17+
18+
describe('back', () => {
19+
beforeEach(() => vi.clearAllMocks());
20+
21+
it('sends Alt+Left when a window is active', async () => {
22+
const { keyDown, keyUp } = await import('../../../lib/winapi/user32');
23+
const driver = createMockDriver() as any;
24+
driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID);
25+
26+
await back.call(driver);
27+
28+
expect(keyDown).toHaveBeenNthCalledWith(1, Key.ALT);
29+
expect(keyDown).toHaveBeenNthCalledWith(2, Key.LEFT);
30+
expect(keyUp).toHaveBeenNthCalledWith(1, Key.LEFT);
31+
expect(keyUp).toHaveBeenNthCalledWith(2, Key.ALT);
32+
});
33+
34+
it('throws NoSuchWindowError when no active window', async () => {
35+
const driver = createMockDriver() as any;
36+
driver.sendPowerShellCommand.mockResolvedValue('');
37+
38+
await expect(back.call(driver)).rejects.toThrow('No active window found');
39+
});
40+
41+
it('performs exactly one PS call (window check) before sending keys', async () => {
42+
const driver = createMockDriver() as any;
43+
driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID);
44+
45+
await back.call(driver);
46+
47+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1);
48+
});
49+
});
50+
51+
describe('forward', () => {
52+
beforeEach(() => vi.clearAllMocks());
53+
54+
it('sends Alt+Right when a window is active', async () => {
55+
const { keyDown, keyUp } = await import('../../../lib/winapi/user32');
56+
const driver = createMockDriver() as any;
57+
driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID);
58+
59+
await forward.call(driver);
60+
61+
expect(keyDown).toHaveBeenNthCalledWith(1, Key.ALT);
62+
expect(keyDown).toHaveBeenNthCalledWith(2, Key.RIGHT);
63+
expect(keyUp).toHaveBeenNthCalledWith(1, Key.RIGHT);
64+
expect(keyUp).toHaveBeenNthCalledWith(2, Key.ALT);
65+
});
66+
67+
it('throws NoSuchWindowError when no active window', async () => {
68+
const driver = createMockDriver() as any;
69+
driver.sendPowerShellCommand.mockResolvedValue('');
70+
71+
await expect(forward.call(driver)).rejects.toThrow('No active window found');
72+
});
73+
});
74+
75+
describe('title (getTitle)', () => {
76+
beforeEach(() => vi.clearAllMocks());
77+
78+
it('returns the window title from the Name property', async () => {
79+
const driver = createMockDriver() as any;
80+
driver.sendPowerShellCommand
81+
.mockResolvedValueOnce(ELEMENT_ID) // window check
82+
.mockResolvedValueOnce('Untitled - Notepad'); // Name property
83+
84+
const result = await title.call(driver);
85+
86+
expect(result).toBe('Untitled - Notepad');
87+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2);
88+
});
89+
90+
it('returns an empty string when the window has no title', async () => {
91+
const driver = createMockDriver() as any;
92+
driver.sendPowerShellCommand
93+
.mockResolvedValueOnce(ELEMENT_ID)
94+
.mockResolvedValueOnce('');
95+
96+
const result = await title.call(driver);
97+
98+
expect(result).toBe('');
99+
});
100+
101+
it('throws NoSuchWindowError when no active window', async () => {
102+
const driver = createMockDriver() as any;
103+
driver.sendPowerShellCommand.mockResolvedValue('');
104+
105+
await expect(title.call(driver)).rejects.toThrow('No active window found');
106+
});
107+
});
108+
109+
describe('setWindowRect', () => {
110+
beforeEach(() => vi.clearAllMocks());
111+
112+
const MOCK_RECT = { x: 100, y: 100, width: 800, height: 600 };
113+
114+
function createDriverWithRect(windowRect = MOCK_RECT) {
115+
const driver = createMockDriver() as any;
116+
driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID);
117+
driver.getWindowRect = vi.fn().mockResolvedValue(windowRect);
118+
return driver;
119+
}
120+
121+
it('calls Move and Resize when all four values are provided', async () => {
122+
const driver = createDriverWithRect();
123+
124+
const result = await setWindowRect.call(driver, 100, 100, 800, 600);
125+
126+
// PS calls: window check + Move + Resize = 3
127+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(3);
128+
expect(driver.getWindowRect).toHaveBeenCalledTimes(1);
129+
expect(result).toEqual(MOCK_RECT);
130+
});
131+
132+
it('calls only Move when width and height are null', async () => {
133+
const driver = createDriverWithRect();
134+
135+
await setWindowRect.call(driver, 50, 75, null, null);
136+
137+
// PS calls: window check + Move = 2
138+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2);
139+
});
140+
141+
it('calls only Resize when x and y are null', async () => {
142+
const driver = createDriverWithRect();
143+
144+
await setWindowRect.call(driver, null, null, 1024, 768);
145+
146+
// PS calls: window check + Resize = 2
147+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2);
148+
});
149+
150+
it('skips Move and Resize when all arguments are null', async () => {
151+
const driver = createDriverWithRect();
152+
153+
await setWindowRect.call(driver, null, null, null, null);
154+
155+
// PS calls: window check only = 1
156+
expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it('returns the new window rect from getWindowRect', async () => {
160+
const expectedRect = { x: 200, y: 300, width: 1024, height: 768 };
161+
const driver = createDriverWithRect(expectedRect);
162+
163+
const result = await setWindowRect.call(driver, 200, 300, 1024, 768);
164+
165+
expect(result).toEqual(expectedRect);
166+
});
167+
168+
it('throws InvalidArgumentError for negative width', async () => {
169+
const driver = createDriverWithRect();
170+
171+
await expect(setWindowRect.call(driver, 0, 0, -1, 600)).rejects.toThrow('width must be a non-negative integer');
172+
});
173+
174+
it('throws InvalidArgumentError for negative height', async () => {
175+
const driver = createDriverWithRect();
176+
177+
await expect(setWindowRect.call(driver, 0, 0, 800, -1)).rejects.toThrow('height must be a non-negative integer');
178+
});
179+
180+
it('throws NoSuchWindowError when no active window', async () => {
181+
const driver = createMockDriver() as any;
182+
driver.sendPowerShellCommand.mockResolvedValue('');
183+
driver.getWindowRect = vi.fn();
184+
185+
await expect(setWindowRect.call(driver, 0, 0, 800, 600)).rejects.toThrow('No active window found');
186+
});
187+
188+
it('the Move PS command contains TransformPattern and Move', async () => {
189+
const driver = createDriverWithRect();
190+
191+
await setWindowRect.call(driver, 10, 20, null, null);
192+
193+
const moveCmdCall = driver.sendPowerShellCommand.mock.calls[1][0] as string;
194+
const decoded = moveCmdCall.replace(/FromBase64String\('([^']+)'\)/g, (_, b64) =>
195+
Buffer.from(b64, 'base64').toString('utf8')
196+
);
197+
expect(decoded).toContain('TransformPattern');
198+
expect(decoded).toContain('Move');
199+
});
200+
201+
it('the Resize PS command contains TransformPattern and Resize', async () => {
202+
const driver = createDriverWithRect();
203+
204+
await setWindowRect.call(driver, null, null, 800, 600);
205+
206+
const resizeCmdCall = driver.sendPowerShellCommand.mock.calls[1][0] as string;
207+
const decoded = resizeCmdCall.replace(/FromBase64String\('([^']+)'\)/g, (_, b64) =>
208+
Buffer.from(b64, 'base64').toString('utf8')
209+
);
210+
expect(decoded).toContain('TransformPattern');
211+
expect(decoded).toContain('Resize');
212+
});
213+
});

0 commit comments

Comments
 (0)