From a4a1fa2b0b20c4494919699e8d307793cf18dc04 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:01:41 +0300 Subject: [PATCH 01/11] chore: configure semantic-release branches for stable and preview releases --- .github/workflows/lint-build.yml | 10 ++++++---- .github/workflows/release.yml | 10 ++++++---- .releaserc | 4 ++++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-build.yml b/.github/workflows/lint-build.yml index 786aaf1..54574d1 100644 --- a/.github/workflows/lint-build.yml +++ b/.github/workflows/lint-build.yml @@ -2,7 +2,9 @@ name: Lint & Build on: pull_request: - branches: [ "main" ] + branches: + - main + - develop jobs: build: @@ -11,12 +13,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 18.x + node-version: 24.x - name: Install dependencies run: npm install --no-package-lock diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b69b340..1b0e746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,18 +3,20 @@ name: Release on: workflow_dispatch: push: - branches: [ main ] + branches: + - main + - develop jobs: build: runs-on: ubuntu-latest environment: Release steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: - node-version: lts/* + node-version: 24.x - run: npm install --no-package-lock name: Install dependencies - run: npm run build diff --git a/.releaserc b/.releaserc index 6b0755e..2bd1f0a 100644 --- a/.releaserc +++ b/.releaserc @@ -1,4 +1,8 @@ { + "branches": [ + "main", + { "name": "develop", "prerelease": "preview" } + ], "plugins": [ ["@semantic-release/commit-analyzer", { "preset": "angular", From 2e08f8d5a1df9bf277b2c521584dddb5b0935e72 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:02:37 +0300 Subject: [PATCH 02/11] fix: update ESLint config --- eslint.config.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6097824..b36e703 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,12 +1,11 @@ // @ts-check +import { defineConfig } from 'eslint/config'; import eslint from '@eslint/js'; -import { config, configs } from 'typescript-eslint'; import appiumConfig from '@appium/eslint-config-appium-ts'; -export default config( - ...appiumConfig, +export default defineConfig( eslint.configs.recommended, - configs.recommended, + ...appiumConfig, ); From 4fd016c5adc091305974b3a41c22423cadf6e3ab Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:06:04 +0300 Subject: [PATCH 03/11] chore: upgrade dependencies and devDependencies to latest versions --- package.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 1f53400..b683289 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "appium": "^2.17.1" }, "dependencies": { - "@appium/base-driver": "^9.16.4", + "@appium/base-driver": "^10.1.0", "bezier-easing": "^2.1.0", - "koffi": "^2.11.0", + "koffi": "^2.14.1", "xpath-analyzer": "^3.0.1" }, "appium": { @@ -44,17 +44,17 @@ "mainClass": "NovaWindowsDriver" }, "devDependencies": { - "@appium/eslint-config-appium-ts": "^1.0.3", - "@appium/tsconfig": "^0.3.5", - "@appium/types": "^0.25.2", - "@eslint/js": "^9.25.1", + "@appium/eslint-config-appium-ts": "^2.0.3", + "@appium/tsconfig": "^1.1.0", + "@appium/types": "^1.1.0", + "@eslint/js": "^9.38.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@types/node": "^22.14.1", - "conventional-changelog-conventionalcommits": "^8.0.0", - "eslint": "^9.25.1", - "semantic-release": "^24.2.3", - "typescript": "^5.8.3", - "typescript-eslint": "^8.31.0" + "@types/node": "^24.8.1", + "conventional-changelog-conventionalcommits": "^9.1.0", + "eslint": "^9.38.0", + "semantic-release": "^25.0.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.1" } } From cdee0ca44a1423312351449b3227035976ba396f Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:06:40 +0300 Subject: [PATCH 04/11] chore: bump peerDependency appium to ^3.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b683289..dcd5845 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "url": "https://github.com/AutomateThePlanet/appium-novawindows-driver/issues" }, "peerDependencies": { - "appium": "^2.17.1" + "appium": "^3.1.0" }, "dependencies": { "@appium/base-driver": "^10.1.0", From 4c7003809c6b6668315ed7e036b5ee6cf3595e51 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:27:44 +0300 Subject: [PATCH 05/11] chore: remove unnecessary ESLint ignore comments --- lib/commands/extension.ts | 1 - lib/commands/index.ts | 1 - lib/commands/powershell.ts | 3 --- lib/enums.ts | 2 -- lib/powershell/conditions.ts | 1 - lib/powershell/core.ts | 1 - lib/util.ts | 1 - 7 files changed, 10 deletions(-) diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 66216fe..59290a7 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -94,7 +94,6 @@ type KeyAction = { down?: boolean, } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export async function execute(this: NovaWindowsDriver, script: string, args: any[]) { if (script.startsWith(PLATFORM_COMMAND_PREFIX)) { script = script.replace(PLATFORM_COMMAND_PREFIX, '').trim(); diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 7469824..9a55d43 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -22,7 +22,6 @@ type Commands = { }; declare module '../driver' { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface NovaWindowsDriver extends Commands {} } diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index 47c2ba4..52d1c3f 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -15,12 +15,10 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise { this.powerShellStdOut += chunk.toString(); }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any powerShell.stderr.on('data', (chunk: any) => { this.powerShellStdErr += chunk.toString(); }); @@ -88,7 +86,6 @@ export async function sendPowerShellCommand(this: NovaWindowsDriver, command: st powerShell.stdin.write(`${command}\n`); powerShell.stdin.write(/* ps1 */ `Write-Output $([char]0x${magicNumber.toString(16)})\n`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const onData: Parameters[1] = ((chunk: any) => { const magicChar = String.fromCharCode(magicNumber); if (chunk.toString().includes(magicChar)) { diff --git a/lib/enums.ts b/lib/enums.ts index 036c77d..8b9f151 100644 --- a/lib/enums.ts +++ b/lib/enums.ts @@ -4,9 +4,7 @@ type Enumerate = Acc['length'] exte type IntRange = Exclude, Enumerate> | T -// eslint-disable-next-line @typescript-eslint/no-explicit-any type AllFlagsValue = [N] extends [Partial['length']] ? A['length'] : UnionFlags; -// eslint-disable-next-line @typescript-eslint/no-explicit-any type UnionFlags = IntRange<0, AllFlagsValue>; export type Enum = T[keyof T]; diff --git a/lib/powershell/conditions.ts b/lib/powershell/conditions.ts index dd60bd5..83992b4 100644 --- a/lib/powershell/conditions.ts +++ b/lib/powershell/conditions.ts @@ -162,7 +162,6 @@ export class FalseCondition extends Condition { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any function assertPSObjectType(obj: PSObject, type: new (...args: any[]) => PSObject) { if (!(obj instanceof type)) { throw new errors.InvalidArgumentError(`Property expected type ${type.name} but got ${(obj as object)?.constructor.name}.`); diff --git a/lib/powershell/core.ts b/lib/powershell/core.ts index 7a4613c..749aa78 100644 --- a/lib/powershell/core.ts +++ b/lib/powershell/core.ts @@ -20,7 +20,6 @@ export function pwsh(strings: TemplateStringsArray, ...values: string[]): string export function pwsh$(literals: TemplateStringsArray, ...substitutions: number[]) { const templateInstance = $(literals, ...substitutions); const defaultFormat = templateInstance.format.bind(templateInstance); - // eslint-disable-next-line @typescript-eslint/no-explicit-any templateInstance.format = (...args: any[]) => { const command = defaultFormat(...args); return /* ps1 */ `(Invoke-Expression -Command ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${btoa(command)}'))))`; diff --git a/lib/util.ts b/lib/util.ts index 5ca8605..0453e98 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -41,7 +41,6 @@ export class DeferredStringTemplate { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any format(...args: any[]): string { const out: string[] = []; for (let i = 0, k = 0; i < this.literals.length; i++, k++) { From 5a581ae7ae1e1a013cb8e332454f70762f8749c7 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:29:58 +0300 Subject: [PATCH 06/11] feat: add appWorkingDir, prerun, postrun, and isolatedScriptExecution capabilities --- lib/commands/extension.ts | 6 ++- lib/commands/powershell.ts | 83 ++++++++++++++++++++++++++++++++++++++ lib/constraints.ts | 14 ++++++- lib/driver.ts | 11 +++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 59290a7..3c495ad 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -292,7 +292,11 @@ export async function executePowerShellScript(this: NovaWindowsDriver, script: s } const scriptToExecute = pwsh`${script}`; - return await this.sendPowerShellCommand(scriptToExecute); + if (this.caps.isolatedScriptExecution) { + return await this.sendIsolatedPowerShellCommand(scriptToExecute); + } else { + return await this.sendPowerShellCommand(scriptToExecute); + } } export async function executeKeys(this: NovaWindowsDriver, keyActions: { actions: KeyAction | KeyAction[], forceUnicode: boolean }) { diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index 52d1c3f..230709a 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -25,6 +25,20 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise = new Set(); + const matches = this.caps.appWorkingDir.matchAll(/%([^%]+)%/g); + + for (const match of matches) { + envVarsSet.add(match[1]); + } + const envVars = Array.from(envVarsSet); + for (const envVar of envVars) { + this.caps.appWorkingDir = this.caps.appWorkingDir.replaceAll(`%${envVar}%`, process.env[envVar.toUpperCase()] ?? ''); + } + this.sendPowerShellCommand(`Set-Location -Path '${this.caps.appWorkingDir}'`); + } + await this.sendPowerShellCommand(SET_UTF8_ENCODING); await this.sendPowerShellCommand(ADD_NECESSARY_ASSEMBLIES); await this.sendPowerShellCommand(USE_UI_AUTOMATION_CLIENT); @@ -68,6 +82,75 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise { + const magicNumber = 0xF2EE; + + const powerShell = spawn('powershell.exe', ['-NoExit', '-Command', '-']); + try { + powerShell.stdout.setEncoding('utf8'); + powerShell.stdout.setEncoding('utf8'); + + powerShell.stdout.on('data', (chunk: any) => { + this.powerShellStdOut += chunk.toString(); + }); + + powerShell.stderr.on('data', (chunk: any) => { + this.powerShellStdErr += chunk.toString(); + }); + + const result = await new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const powerShell = this.powerShell!; + + this.powerShellStdOut = ''; + this.powerShellStdErr = ''; + + powerShell.stdin.write(`${SET_UTF8_ENCODING}\n`); + if (this.caps.appWorkingDir) { + const envVarsSet: Set = new Set(); + const matches = this.caps.appWorkingDir.matchAll(/%([^%]+)%/g); + + for (const match of matches) { + envVarsSet.add(match[1]); + } + const envVars = Array.from(envVarsSet); + for (const envVar of envVars) { + this.caps.appWorkingDir = this.caps.appWorkingDir.replaceAll(`%${envVar}%`, process.env[envVar.toUpperCase()] ?? ''); + } + powerShell.stdin.write(`Set-Location -Path '${this.caps.appWorkingDir}'\n`); + } + powerShell.stdin.write(`${command}\n`); + powerShell.stdin.write(/* ps1 */ `Write-Output $([char]0x${magicNumber.toString(16)})\n`); + + const onData: Parameters[1] = ((chunk: any) => { + const magicChar = String.fromCharCode(magicNumber); + if (chunk.toString().includes(magicChar)) { + powerShell.stdout.off('data', onData); + if (this.powerShellStdErr) { + reject(new errors.UnknownError(this.powerShellStdErr)); + } else { + resolve(this.powerShellStdOut.replace(`${magicChar}`, '').trim()); + } + } + }).bind(this); + + powerShell.stdout.on('data', onData); + }); + + // commented out for now to avoid cluttering the logs with long command outputs + // this.log.debug(`PowerShell command executed:\n${command}\n\nCommand output below:\n${result}\n --------`); + + return result; + } finally { + // Ensure the isolated PowerShell process is terminated + try { + powerShell.kill(); + } catch (e) { + this.log.warn(`Failed to terminate isolated PowerShell process: ${e}`); + } + } +} + export async function sendPowerShellCommand(this: NovaWindowsDriver, command: string): Promise { const magicNumber = 0xF2EE; diff --git a/lib/constraints.ts b/lib/constraints.ts index fb8149e..ce24b0a 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -22,8 +22,20 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { isBoolean: true, }, appArguments: { - isString: true + isString: true, + }, + appWorkingDir: { + isString: true, }, + prerun: { + isObject: true, + }, + postrun: { + isObject: true, + }, + isolatedScriptExecution: { + isBoolean: true, + } } as const satisfies Constraints; export default UI_AUTOMATION_DRIVER_CONSTRAINTS; diff --git a/lib/driver.ts b/lib/driver.ts index a44e07f..8e7a551 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -178,6 +178,11 @@ export class NovaWindowsDriver extends BaseDriver[0], string>); + } + setDpiAwareness(); this.log.debug(`Started session ${sessionId}.`); return [sessionId, caps]; @@ -202,6 +207,12 @@ export class NovaWindowsDriver extends BaseDriver[0], string>); + } + await super.deleteSession(sessionId); } From 22586a237f20e975adee25c13fba8c649420574d Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:33:04 +0300 Subject: [PATCH 07/11] feat: add "none" session option to start without attaching to any element --- lib/commands/app.ts | 13 +++++++++++++ lib/commands/powershell.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/commands/app.ts b/lib/commands/app.ts index fc20705..2f2da69 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -23,11 +23,24 @@ import { const GET_PAGE_SOURCE_COMMAND = pwsh$ /* ps1 */ ` $el = ${0} + if ($el -eq $null) { + $dummy = [xml]'' + return $dummy.OuterXml + } + Get-PageSource $el | ForEach-Object { $_.OuterXml } `; const GET_SCREENSHOT_COMMAND = pwsh /* ps1 */ ` + if ($rootElement -eq $null) { + $bitmap = New-Object Drawing.Bitmap 1,1 + $stream = New-Object IO.MemoryStream + $bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + return [Convert]::ToBase64String($stream.ToArray()) + } + $rect = $rootElement.Current.BoundingRectangle $bitmap = New-Object Drawing.Bitmap([int32]$rect.Width, [int32]$rect.Height) diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index 230709a..c484ebe 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -8,6 +8,7 @@ const ADD_NECESSARY_ASSEMBLIES = /* ps1 */ `Add-Type -AssemblyName UIAutomationC const USE_UI_AUTOMATION_CLIENT = /* ps1 */ `using namespace System.Windows.Automation`; const INIT_CACHE_REQUEST = /* ps1 */ `($cacheRequest = New-Object System.Windows.Automation.CacheRequest).TreeFilter = [AndCondition]::new([Automation]::ControlViewCondition, [NotCondition]::new([PropertyCondition]::new([AutomationElement]::FrameworkIdProperty, 'Chrome'))); $cacheRequest.Push()`; const INIT_ROOT_ELEMENT = /* ps1 */ `$rootElement = [AutomationElement]::RootElement`; +const NULL_ROOT_ELEMENT = /* ps1 */ `$rootElement = $null`; const INIT_ELEMENT_TABLE = /* ps1 */ `$elementTable = New-Object System.Collections.Generic.Dictionary[[string]\`,[AutomationElement]]`; export async function startPowerShellSession(this: NovaWindowsDriver): Promise { @@ -49,11 +50,15 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise = new Set(); const matches = this.caps.app.matchAll(/%([^%]+)%/g); From 5da452fa71608d3f52a92c7ea6f82a78ff3139a6 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:33:20 +0300 Subject: [PATCH 08/11] chore: add extra logging --- lib/commands/app.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/commands/app.ts b/lib/commands/app.ts index 2f2da69..8f97548 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -117,12 +117,13 @@ export async function setWindow(this: NovaWindowsDriver, nameOrHandle: string): const elementId = await this.sendPowerShellCommand(AutomationElement.rootElement.findFirst(TreeScope.CHILDREN, condition).buildCommand()); if (elementId.trim() !== '') { + this.log.info(`Found window with name '${name}'. Setting it as the root element.`); await this.sendPowerShellCommand(/* ps1 */ `$rootElement = ${new FoundAutomationElement(elementId).buildCommand()}`); trySetForegroundWindow(handle); return; } - this.log.info(`Failed to locate window. Sleeping for 500 milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout + this.log.info(`Failed to locate window with name '${name}'. Sleeping for 500 milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout await sleep(500); // TODO: make a setting for the sleep timeout } @@ -149,12 +150,14 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin const path = pathOrNativeWindowHandle; if (path.includes('!') && path.includes('_') && !(path.includes('/') || path.includes('\\'))) { + this.log.debug('Detected app path to be in the UWP format.'); await this.sendPowerShellCommand(/* ps1 */ `Start-Process 'explorer.exe' 'shell:AppsFolder\\${path}'${this.caps.appArguments ? ` -ArgumentList '${this.caps.appArguments}'` : ''}`); await sleep(500); // TODO: make a setting for the initial wait time for (let i = 1; i <= 20; i++) { const result = await this.sendPowerShellCommand(/* ps1 */ `(Get-Process -Name 'ApplicationFrameHost').Id`); const processIds = result.split('\n').map((pid) => pid.trim()).filter(Boolean).map(Number); + this.log.debug('Process IDs of ApplicationFrameHost processes: ' + processIds.join(', ')); try { await this.attachToApplicationWindow(processIds); return; @@ -166,6 +169,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin await sleep(500); // TODO: make a setting for the sleep timeout } } else { + this.log.debug('Detected app path to be in the classic format.'); const normalizedPath = normalize(path); await this.sendPowerShellCommand(/* ps1 */ `Start-Process '${normalizedPath}'${this.caps.appArguments ? ` -ArgumentList '${this.caps.appArguments}'` : ''}`); await sleep(500); // TODO: make a setting for the initial wait time @@ -176,6 +180,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin const processName = executable.endsWith('.exe') ? executable.slice(0, executable.length - 4) : executable; const result = await this.sendPowerShellCommand(/* ps1 */ `(Get-Process -Name '${processName}' | Sort-Object StartTime -Descending).Id`); const processIds = result.split('\n').map((pid) => pid.trim()).filter(Boolean).map(Number); + this.log.debug(`Process IDs of '${processName}' processes: ` + processIds.join(', ')); await this.attachToApplicationWindow(processIds); return; @@ -195,6 +200,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin export async function attachToApplicationWindow(this: NovaWindowsDriver, processIds: number[]): Promise { const nativeWindowHandles = getWindowAllHandlesForProcessIds(processIds); + 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(', ')}`); if (nativeWindowHandles.length !== 0) { const elementId = await this.sendPowerShellCommand(AutomationElement.rootElement.findFirst(TreeScope.CHILDREN, new PropertyCondition(Property.NATIVE_WINDOW_HANDLE, new PSInt32(nativeWindowHandles[0]))).buildCommand()); From 7a05300ef4a0792a9c1160dfab55537c96967f08 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 09:36:12 +0300 Subject: [PATCH 09/11] fix: make modifierKeys case-insensitive --- lib/commands/extension.ts | 51 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 3c495ad..7ab69fe 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -414,22 +414,23 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: { }; const mouseButton: number = clickTypeToButtonMapping[button]; + const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]; await mouseMoveAbsolute(pos[0], pos[1], 0); for (let i = 0; i < times; i++) { if (i !== 0) { await sleep(interClickDelayMs); } - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyDown(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyDown(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyDown(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyDown(Key.META); } @@ -439,16 +440,16 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: { } mouseUp(mouseButton); - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyUp(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyUp(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyUp(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyUp(Key.META); } } @@ -493,6 +494,7 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { throw new errors.InvalidArgumentError('Both endX and endY must be provided.'); } + const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]; let startPos: [number, number]; if (startElementId) { if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(startElementId).toString()}`)) { @@ -534,31 +536,31 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { await mouseMoveAbsolute(startPos[0], startPos[1], 0); - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyDown(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyDown(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyDown(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyDown(Key.META); } await mouseMoveAbsolute(endPos[0], endPos[1], durationMs, this.caps.smoothPointerMove); - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyUp(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyUp(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyUp(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyUp(Key.META); } } @@ -586,6 +588,7 @@ export async function executeScroll(this: NovaWindowsDriver, scrollArgs: { throw new errors.InvalidArgumentError('Both x and y must be provided.'); } + const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]; let pos: [number, number]; if (elementId) { if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(elementId).toString()}`)) { @@ -607,31 +610,31 @@ export async function executeScroll(this: NovaWindowsDriver, scrollArgs: { await mouseMoveAbsolute(pos[0], pos[1], 0); - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyDown(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyDown(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyDown(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyDown(Key.META); } mouseScroll(deltaX ?? 0, deltaY ?? 0); - if (modifierKeys.includes('ctrl')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { keyUp(Key.CONTROL); } - if (modifierKeys.includes('alt')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { keyUp(Key.ALT); } - if (modifierKeys.includes('shift')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { keyUp(Key.SHIFT); } - if (modifierKeys.includes('win')) { + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { keyUp(Key.META); } } From 2d01246e009e2c7fd67165fc1d313446870021d3 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 10:01:28 +0300 Subject: [PATCH 10/11] fix: allow elementId with optional x/y offsets for click/hover --- lib/commands/extension.ts | 42 ++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 7ab69fe..e37ea69 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -378,12 +378,8 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: { interClickDelayMs = 100, } = clickArgs; - if (!!elementId && ((x !== null && x !== undefined) || (y !== null && y !== undefined))) { - throw new errors.InvalidArgumentError('Either elementId or x and y must be provided.'); - } - - if ((x !== null && x !== undefined) !== (y !== null && y !== undefined)) { - throw new errors.InvalidArgumentError('Both x and y must be provided.'); + if ((x != null) !== (y != null)) { + throw new errors.InvalidArgumentError('Both x and y must be provided if either is set.'); } let pos: [number, number]; @@ -399,7 +395,10 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: { const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(elementId).buildGetElementRectCommand()); const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; - pos = [rect.x + (rect.width / 2), rect.y + (rect.height / 2)]; + pos = [ + rect.x + (x ?? Math.trunc(rect.width / 2)), + rect.y + (y ?? Math.trunc(rect.height / 2)), + ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pos = [x!, y!]; @@ -466,7 +465,7 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { endElementId?: string, endX?: number, endY?: number, - modifierKeys?: ('shift' | 'ctrl' | 'alt' | 'win') | ('shift' | 'ctrl' | 'alt' | 'win')[], // TODO: add types + modifierKeys?: ('shift' | 'ctrl' | 'alt' | 'win') | ('shift' | 'ctrl' | 'alt' | 'win')[], durationMs?: number, }) { const { @@ -478,20 +477,12 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { durationMs = 500, } = hoverArgs; - if (!!startElementId && ((startX !== null && startX !== undefined) || (startY !== null && startY !== undefined))) { - throw new errors.InvalidArgumentError('Either startElementId or startX and startY must be provided.'); - } - - if (!!endElementId && ((endX !== null && endX !== undefined) || (endY !== null && endY !== undefined))) { - throw new errors.InvalidArgumentError('Either endElementId or endX and endY must be provided.'); + if ((startX != null) !== (startY != null)) { + throw new errors.InvalidArgumentError('Both startX and startY must be provided if either is set.'); } - if ((startX !== null && startX !== undefined) !== (startY !== null && startY !== undefined)) { - throw new errors.InvalidArgumentError('Both startX and startY must be provided.'); - } - - if ((endX !== null && endX !== undefined) !== (endY !== null && endY !== undefined)) { - throw new errors.InvalidArgumentError('Both endX and endY must be provided.'); + if ((endX != null) !== (endY != null)) { + throw new errors.InvalidArgumentError('Both endX and endY must be provided if either is set.'); } const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]; @@ -508,13 +499,15 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(startElementId).buildGetElementRectCommand()); const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; - startPos = [rect.x + (rect.width / 2), rect.y + (rect.height / 2)]; + startPos = [ + rect.x + (startX ?? rect.width / 2), + rect.y + (startY ?? rect.height / 2) + ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion startPos = [startX!, startY!]; } - let endPos: [number, number]; if (endElementId) { if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(endElementId).toString()}`)) { @@ -528,7 +521,10 @@ export async function executeHover(this: NovaWindowsDriver, hoverArgs: { const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(endElementId).buildGetElementRectCommand()); const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; - endPos = [rect.x + (rect.width / 2), rect.y + (rect.height / 2)]; + endPos = [ + rect.x + (endX ?? rect.width / 2), + rect.y + (endY ?? rect.height / 2) + ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion endPos = [endX!, endY!]; From 9c1d43bc5ad41f136a9a033d39a5a091f3a1c6a4 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Mon, 20 Oct 2025 10:15:45 +0300 Subject: [PATCH 11/11] docs: update README --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 56a22f9..84d80f9 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@ It’s designed to handle real-world scenarios where traditional drivers fall sh > **Note** > -> This driver is built for Appium 2 and is not compatible with Appium 1. To get started, -> clone the repository and run `npm install` to resolve dependencies. Then, use `npm run build` -> to build the driver. Finally, add it to your Appium 2 distribution with the command: +> This driver is built for Appium 2/3 and is not compatible with Appium 1. To install +> the driver, simply run: > `appium driver install --source=npm appium-novawindows-driver` @@ -44,6 +43,11 @@ delayBeforeClick | Time in milliseconds before a click is performed. delayAfterClick | Time in milliseconds after a click is performed. appTopLevelWindow | The handle of an existing application top-level window to attach to. It can be a number or string (not necessarily hexadecimal). Example: `12345`, `0x12345`. shouldCloseApp | Whether to close the window of the application in test after the session finishes. Default is `true`. +appArguments | Optional string of arguments to pass to the app on launch. +appWorkingDir | Optional working directory path for the application. +prerun | An object containing either `script` or `command` key. The value of each key must be a valid PowerShell script or command to be executed prior to the WinAppDriver session startup. See [Power Shell commands execution](#power-shell-commands-execution) for more details. Example: `{script: 'Get-Process outlook -ErrorAction SilentlyContinue'}` +postrun | An object containing either `script` or `command` key. The value of each key must be a valid PowerShell script or command to be executed after WinAppDriver session is stopped. See [Power Shell commands execution](#power-shell-commands-execution) for more details. +isolatedScriptExecution | Whether PowerShell scripts are executed in an isolated session. Default is `false`. Please note that more capabilities will be added as the development of this driver progresses. Since it is still in its early stages, some features may be missing or subject to change. If you need a specific capability or encounter any issues, please feel free to open an issue. @@ -103,8 +107,7 @@ key to the listof enabled insecure features. Refer to [Appium Security document] It is possible to ether execute a single Power Shell command or a whole script and get its stdout in response. If the script execution returns non-zero exit code then an exception is going to be thrown. The exception message will contain the actual stderr. Unlike, Appium Windows Driver, -there is no difference if you paste the script with `command` or `script` argument. For ease of use, you -can even pass the script as a string only, it will work the same way. +there is no difference if you paste the script with `command` or `script` argument. For ease of use, you can pass the script as a string when executing a PowerShell command directly via the driver. Note: This shorthand does not work when using the prerun or postrun capabilities, which require full object syntax. Here's an example code of how to control the Notepad process: ```java