Skip to content

Commit 47efa4c

Browse files
committed
fix: fix bugs and implemented end to end tests
fix multiple bugs: patternGetValue missing return, clipboard PS quoting, regex group accessor, command binding to prototype vs instance, isolated PS shared buffers, mouseScroll event not enqueued, getDeviceTime signature mismatch; add windows: getWindowElement command; Implement end to end tests with real calculator, notepad and todo apps
1 parent 97b57af commit 47efa4c

27 files changed

+2060
-67
lines changed

README.md

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -545,57 +545,28 @@ recursive | boolean | no | If true (default), delete contents recursively. If fa
545545

546546
### windows: launchApp
547547

548-
Launches an application and waits for it to start. Supports both classic Win32 apps (by path) and UWP apps (by App User Model ID).
548+
Re-launches the application configured in the `app` session capability. The app path or App User Model ID (AUMID) must have been set when the session was created. Typically used to reopen an app after it has been closed with `windows: closeApp`.
549549

550-
#### Arguments
551-
552-
Name | Type | Required | Description | Example
553-
--- | --- | --- | --- | ---
554-
app | string | yes | Path to the executable or UWP App User Model ID (AUMID). Classic format: `C:\Path\To\app.exe`. UWP format: `Microsoft.WindowsCalculator_8wekyb3d8bbwe!App` | notepad.exe
555-
appArguments | string | no | Command-line arguments to pass to the application. | --some-flag
550+
This command takes no arguments.
556551

557552
#### Example
558553

559-
```csharp
560-
// Launch Notepad
561-
driver.ExecuteScript("windows: launchApp", new Dictionary<string, object> { { "app", "notepad.exe" } });
562-
563-
// Launch Calculator (UWP)
564-
driver.ExecuteScript("windows: launchApp", new Dictionary<string, object> {
565-
{ "app", "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App" }
566-
});
567-
568-
// Launch with arguments
569-
driver.ExecuteScript("windows: launchApp", new Dictionary<string, object> {
570-
{ "app", "notepad.exe" },
571-
{ "appArguments", "C:\\path\\to\\file.txt" }
572-
});
554+
```javascript
555+
// Re-launch the app set in the session capability
556+
await driver.executeScript('windows: launchApp', []);
573557
```
574558

575559
### windows: closeApp
576560

577-
Terminates a running application by process ID, process name, or window handle. All three methods force-kill the process (same outcome). For graceful window close, use `windows: close` with an element. Exactly one identifier must be provided.
578-
579-
#### Arguments
580-
581-
Name | Type | Required | Description | Example
582-
--- | --- | --- | --- | ---
583-
processId | number | no | Process ID (PID) of the application to terminate. Uses `Stop-Process -Id`. | 12345
584-
processName | string | no | Process name (e.g. executable name without extension). Terminates all processes with that name. Uses `Stop-Process -Name`. | notepad
585-
windowHandle | string or number | no | Native window handle of the application window. Resolves the window to its process ID, then terminates it via `Stop-Process -Id`. Accepts hex string (e.g. `"0x12345678"`) or number. | 0x12345678
561+
Closes the current root application window by sending a close command via the Windows UI Automation WindowPattern. Clears the root element reference in the session afterward. Throws a `NoSuchWindowError` if no active window is found.
586562

563+
This command takes no arguments.
587564

588565
#### Example
589566

590-
```csharp
591-
// Close by process ID
592-
driver.ExecuteScript("windows: closeApp", new Dictionary<string, object> { { "processId", 12345 } });
593-
594-
// Close by process name (e.g. all Notepad instances)
595-
driver.ExecuteScript("windows: closeApp", new Dictionary<string, object> { { "processName", "notepad" } });
596-
597-
// Close by window handle (e.g. from getWindowHandle)
598-
driver.ExecuteScript("windows: closeApp", new Dictionary<string, object> { { "windowHandle", "0x12345678" } });
567+
```javascript
568+
// Close the current app window
569+
await driver.executeScript('windows: closeApp', []);
599570
```
600571

601572
### windows: clickAndDrag

eslint.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ import appiumConfig from '@appium/eslint-config-appium-ts';
88
export default defineConfig(
99
eslint.configs.recommended,
1010
...appiumConfig,
11+
{
12+
files: ['test/e2e/**/*.ts'],
13+
},
1114
);

lib/commands/actions.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,33 @@ export async function handleKeyAction(this: NovaWindowsDriver, action: KeyAction
227227
}
228228
}
229229
}
230+
231+
export async function releaseActions(this: NovaWindowsDriver): Promise<void> {
232+
if (this.keyboardState.shift) {
233+
keyUp(Key.SHIFT);
234+
keyUp(Key.R_SHIFT);
235+
this.keyboardState.shift = false;
236+
}
237+
if (this.keyboardState.ctrl) {
238+
keyUp(Key.CONTROL);
239+
keyUp(Key.R_CONTROL);
240+
this.keyboardState.ctrl = false;
241+
}
242+
if (this.keyboardState.meta) {
243+
keyUp(Key.META);
244+
keyUp(Key.R_META);
245+
this.keyboardState.meta = false;
246+
}
247+
if (this.keyboardState.alt) {
248+
keyUp(Key.ALT);
249+
keyUp(Key.R_ALT);
250+
this.keyboardState.alt = false;
251+
}
252+
for (const key of this.keyboardState.pressed) {
253+
keyUp(key);
254+
}
255+
this.keyboardState.pressed.clear();
256+
mouseUp(0);
257+
mouseUp(1);
258+
mouseUp(2);
259+
}

lib/commands/device.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { PSString, pwsh$ } from '../powershell';
44
const GET_SYSTEM_TIME_COMMAND = pwsh$ /* ps1 */ `(Get-Date).ToString(${0})`;
55
const ISO_8061_FORMAT = 'yyyy-MM-ddTHH:mm:sszzz';
66

7-
export async function getDeviceTime(this: NovaWindowsDriver, format?: string): Promise<string> {
8-
format = format ? new PSString(format).toString() : `'${ISO_8061_FORMAT}'`;
9-
return await this.sendPowerShellCommand(GET_SYSTEM_TIME_COMMAND.format(format));
7+
export async function getDeviceTime(this: NovaWindowsDriver, _sessionId?: string, format?: string): Promise<string> {
8+
const fmt = format ? new PSString(format).toString() : `'${ISO_8061_FORMAT}'`;
9+
return await this.sendPowerShellCommand(GET_SYSTEM_TIME_COMMAND.format(fmt));
1010
}
1111

1212
// command: 'hideKeyboard'

lib/commands/extension.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ const EXTENSION_COMMANDS = Object.freeze({
6565
deleteFile: 'deleteFile',
6666
deleteFolder: 'deleteFolder',
6767
clickAndDrag: 'executeClickAndDrag',
68+
getDeviceTime: 'windowsGetDeviceTime',
69+
getWindowElement: 'getWindowElement',
6870
} as const);
6971

7072
const ContentType = Object.freeze({
@@ -78,7 +80,7 @@ const TREE_FILTER_COMMAND = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.Tree
7880
const TREE_SCOPE_COMMAND = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.TreeScope = ${0}; $cacheRequest.Push()`;
7981
const AUTOMATION_ELEMENT_MODE = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.AutomationElementMode = ${0}; $cacheRequest.Push()`;
8082

81-
const SET_PLAINTEXT_CLIPBOARD_FROM_BASE64 = $ /* ps1 */ `Set-Clipboard -Value [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${0}))`;
83+
const SET_PLAINTEXT_CLIPBOARD_FROM_BASE64 = $ /* ps1 */ `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${0})))`;
8284
const GET_PLAINTEXT_CLIPBOARD_BASE64 = /* ps1 */ `[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard)))`;
8385

8486
const SET_IMAGE_CLIPBOARD_FROM_BASE64 = $ /* ps1 */ `$b = [Convert]::FromBase64String(${0}); $s = New-Object IO.MemoryStream; $s.Write($b, 0, $b.Length); $s.Position = 0; $i = [System.Windows.Media.Imaging.BitmapFrame]::Create($s); [Windows.Clipboard]::SetImage($i); $s.Close()`;
@@ -148,7 +150,7 @@ export async function pushCacheRequest(this: NovaWindowsDriver, cacheRequest: Ca
148150
}
149151

150152
if (cacheRequest.treeScope) {
151-
const treeScope = TREE_SCOPE_REGEX.exec(cacheRequest.treeScope)?.groups?.[0];
153+
const treeScope = TREE_SCOPE_REGEX.exec(cacheRequest.treeScope)?.[1];
152154
if (!treeScope || (Number(cacheRequest.treeScope) < 1 && Number(cacheRequest.treeScope) > 16)) {
153155
throw new errors.InvalidArgumentError(`Invalid value '${cacheRequest.treeScope}' passed to TreeScope for cache request.`);
154156
}
@@ -157,7 +159,7 @@ export async function pushCacheRequest(this: NovaWindowsDriver, cacheRequest: Ca
157159
}
158160

159161
if (cacheRequest.automationElementMode) {
160-
const treeScope = AUTOMATION_ELEMENT_MODE_REGEX.exec(cacheRequest.automationElementMode)?.groups?.[0];
162+
const treeScope = AUTOMATION_ELEMENT_MODE_REGEX.exec(cacheRequest.automationElementMode)?.[1];
161163

162164
if (!treeScope || (Number(cacheRequest.automationElementMode) < 0 && Number(cacheRequest.automationElementMode) > 1)) {
163165
throw new errors.InvalidArgumentError(`Invalid value '${cacheRequest.automationElementMode}' passed to AutomationElementMode for cache request.`);
@@ -235,8 +237,8 @@ export async function patternSetValue(this: NovaWindowsDriver, element: Element,
235237
}
236238
}
237239

238-
export async function patternGetValue(this: NovaWindowsDriver, element: Element): Promise<void> {
239-
await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildGetValueCommand());
240+
export async function patternGetValue(this: NovaWindowsDriver, element: Element): Promise<string> {
241+
return await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildGetValueCommand());
240242
}
241243

242244
export async function patternMaximize(this: NovaWindowsDriver, element: Element): Promise<void> {
@@ -291,9 +293,9 @@ export async function setClipboardFromBase64(this: NovaWindowsDriver, args: { co
291293

292294
switch (contentType.toLowerCase()) {
293295
case ContentType.PLAINTEXT:
294-
return await this.sendPowerShellCommand(SET_PLAINTEXT_CLIPBOARD_FROM_BASE64.format(args.b64Content));
296+
return await this.sendPowerShellCommand(SET_PLAINTEXT_CLIPBOARD_FROM_BASE64.format(`'${args.b64Content}'`));
295297
case ContentType.IMAGE:
296-
return await this.sendPowerShellCommand(SET_IMAGE_CLIPBOARD_FROM_BASE64.format(args.b64Content));
298+
return await this.sendPowerShellCommand(SET_IMAGE_CLIPBOARD_FROM_BASE64.format(`'${args.b64Content}'`));
297299
default:
298300
throw new errors.InvalidArgumentError(`Unsupported content type '${contentType}'.`);
299301
}
@@ -867,3 +869,16 @@ export async function executeClickAndDrag(this: NovaWindowsDriver, dragArgs: {
867869
keyUp(Key.META);
868870
}
869871
}
872+
873+
export async function windowsGetDeviceTime(this: NovaWindowsDriver, args?: { format?: string }): Promise<string> {
874+
return this.getDeviceTime(undefined, args?.format);
875+
}
876+
877+
export async function getWindowElement(this: NovaWindowsDriver): Promise<Element> {
878+
const result = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand());
879+
const elementId = result.split('\n').map((id) => id.trim()).filter(Boolean)[0];
880+
if (!elementId) {
881+
throw new errors.NoSuchWindowError('No active app window is found for this session.');
882+
}
883+
return { [W3C_ELEMENT_KEY]: elementId };
884+
}

lib/commands/powershell.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,20 @@ export async function sendIsolatedPowerShellCommand(this: NovaWindowsDriver, com
9898
powerShell.stdout.setEncoding('utf8');
9999
powerShell.stdout.setEncoding('utf8');
100100

101+
let localStdOut = '';
102+
let localStdErr = '';
103+
101104
powerShell.stdout.on('data', (chunk: any) => {
102-
this.powerShellStdOut += chunk.toString();
105+
localStdOut += chunk.toString();
103106
});
104107

105108
powerShell.stderr.on('data', (chunk: any) => {
106-
this.powerShellStdErr += chunk.toString();
109+
localStdErr += chunk.toString();
107110
});
108111

109112
const result = await new Promise<string>((resolve, reject) => {
110-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
111-
const powerShell = this.powerShell!;
112-
113-
this.powerShellStdOut = '';
114-
this.powerShellStdErr = '';
113+
localStdOut = '';
114+
localStdErr = '';
115115

116116
powerShell.stdin.write(`${SET_UTF8_ENCODING}\n`);
117117
if (this.caps.appWorkingDir) {
@@ -130,17 +130,17 @@ export async function sendIsolatedPowerShellCommand(this: NovaWindowsDriver, com
130130
powerShell.stdin.write(`${command}\n`);
131131
powerShell.stdin.write(/* ps1 */ `Write-Output $([char]0x${magicNumber.toString(16)})\n`);
132132

133-
const onData: Parameters<typeof powerShell.stdout.on>[1] = ((chunk: any) => {
133+
const onData: Parameters<typeof powerShell.stdout.on>[1] = (chunk: any) => {
134134
const magicChar = String.fromCharCode(magicNumber);
135135
if (chunk.toString().includes(magicChar)) {
136136
powerShell.stdout.off('data', onData);
137-
if (this.powerShellStdErr) {
138-
reject(new errors.UnknownError(this.powerShellStdErr));
137+
if (localStdErr) {
138+
reject(new errors.UnknownError(localStdErr));
139139
} else {
140-
resolve(this.powerShellStdOut.replace(`${magicChar}`, '').trim());
140+
resolve(localStdOut.replace(`${magicChar}`, '').trim());
141141
}
142142
}
143-
}).bind(this);
143+
};
144144

145145
powerShell.stdout.on('data', onData);
146146
});

lib/driver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ export class NovaWindowsDriver extends BaseDriver<NovaWindowsDriverConstraints,
7575
this.locatorStrategies = [...LOCATION_STRATEGIES];
7676
this.desiredCapConstraints = UI_AUTOMATION_DRIVER_CONSTRAINTS;
7777

78+
// Bind commands to this instance (not prototype) so each driver instance uses its own
79+
// PowerShell session and state when multiple sessions exist
7880
for (const key in commands) { // TODO: create a decorator that will do that for the class
79-
NovaWindowsDriver.prototype[key] = commands[key].bind(this);
81+
(this as any)[key] = commands[key].bind(this);
8082
}
8183
}
8284

lib/winapi/user32.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ function makeMouseMoveEvents(args: {
449449
const verticalScrollEvent = makeEmptyMouseEvent();
450450
verticalScrollEvent.u.mi.dwFlags = MouseEventFlags.MOUSEEVENTF_WHEEL;
451451
verticalScrollEvent.u.mi.mouseData = y;
452+
mouseEvents.push(verticalScrollEvent);
452453
}
453454

454455
return mouseEvents;

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"watch": "tsc -b --watch",
1717
"lint": "eslint .",
1818
"test": "vitest run",
19-
"test:watch": "vitest"
19+
"test:e2e": "vitest run --config vitest.e2e.config.ts"
2020
},
2121
"author": "Automate The Planet",
2222
"license": "Apache-2.0",
@@ -58,6 +58,7 @@
5858
"semantic-release": "^25.0.1",
5959
"typescript": "^5.9.3",
6060
"typescript-eslint": "^8.46.1",
61-
"vitest": "^2.1.0"
61+
"vitest": "^2.1.0",
62+
"webdriverio": "^9.0.0"
6263
}
6364
}

test/commands/device.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,13 @@ describe('getDeviceTime', () => {
3232
expect(cmd).toContain('yyyy-MM-ddTHH:mm:sszzz');
3333
});
3434

35-
it('uses custom format when provided', async () => {
35+
it('uses custom format when provided as second argument', async () => {
3636
const driver = createMockDriver() as any;
3737
driver.sendPowerShellCommand.mockResolvedValue('25/02/2026');
38-
const result = await getDeviceTime.call(driver, 'dd/MM/yyyy');
38+
const result = await getDeviceTime.call(driver, undefined, 'dd/MM/yyyy');
3939
expect(result).toBe('25/02/2026');
4040
const cmd = decodeCommand(driver.sendPowerShellCommand.mock.calls[0][0]);
4141
expect(cmd).toContain('Get-Date');
42-
// The custom format is embedded as a PSString (unicode-escaped)
4342
expect(cmd).toContain('ToString');
4443
});
4544
});

0 commit comments

Comments
 (0)