Skip to content

Commit 4434b99

Browse files
committed
feat: Implemented missing commands
implemented commands: pushFile, pullFile, pullFolder, hideKeyboard, isKeyboardShown, activateApp, terminateApp and isAppInstalled
1 parent b53a984 commit 4434b99

File tree

7 files changed

+858
-24
lines changed

7 files changed

+858
-24
lines changed

eslint.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ export default defineConfig(
1111
{
1212
files: ['test/e2e/**/*.ts'],
1313
},
14+
{
15+
rules: {
16+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
17+
},
18+
},
1419
);

lib/commands/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
pwsh$,
1414
pwsh,
1515
} from '../powershell';
16-
import { sleep } from '../util';
16+
import { isUwpAppId, sleep } from '../util';
1717
import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver';
1818
import {
1919
getWindowAllHandlesForProcessIds,
@@ -183,7 +183,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin
183183

184184

185185
const path = pathOrNativeWindowHandle;
186-
if (path.includes('!') && path.includes('_') && !(path.includes('/') || path.includes('\\'))) {
186+
if (isUwpAppId(path)) {
187187
this.log.debug('Detected app path to be in the UWP format.');
188188
await this.sendPowerShellCommand(/* ps1 */ `Start-Process 'explorer.exe' 'shell:AppsFolder\\${path}'${this.caps.appArguments ? ` -ArgumentList '${this.caps.appArguments}'` : ''}`);
189189
const result = await this.sendPowerShellCommand(/* ps1 */ `(Get-Process -Name 'ApplicationFrameHost').Id`);
@@ -202,7 +202,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin
202202
const breadcrumbs = normalizedPath.toLowerCase().split('\\').flatMap((x) => x.split('/'));
203203
const executable = breadcrumbs[breadcrumbs.length - 1];
204204
const processName = executable.endsWith('.exe') ? executable.slice(0, executable.length - 4) : executable;
205-
const result = await this.sendPowerShellCommand(/* ps1 */ `(Get-Process -Name '${processName}' | Sort-Object StartTime -Descending).Id`);
205+
const result = await this.sendPowerShellCommand(/* ps1 */ `(Get-Process -Name '${processName}' | Sort-Object StartTime -Descending).Id`);
206206
const processIds = result.split('\n').map((pid) => pid.trim()).filter(Boolean).map(Number);
207207
this.log.debug(`Process IDs of '${processName}' processes: ` + processIds.join(', '));
208208
this.appProcessIds = processIds;

lib/commands/device.ts

Lines changed: 250 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { normalize } from 'node:path';
2+
import { errors } from '@appium/base-driver';
13
import { NovaWindowsDriver } from '../driver';
2-
import { PSString, pwsh$ } from '../powershell';
4+
import { PSString, pwsh, pwsh$ } from '../powershell';
5+
import { MODIFY_FS_FEATURE } from '../constants';
6+
import { isUwpAppId, sleep } from '../util';
7+
8+
const TERMINATE_POLL_INTERVAL_MS = 200;
9+
const TERMINATE_TIMEOUT_MS = 10_000;
310

411
const GET_SYSTEM_TIME_COMMAND = pwsh$ /* ps1 */ `(Get-Date).ToString(${0})`;
512
const ISO_8061_FORMAT = 'yyyy-MM-ddTHH:mm:sszzz';
@@ -9,33 +16,257 @@ export async function getDeviceTime(this: NovaWindowsDriver, _sessionId?: string
916
return await this.sendPowerShellCommand(GET_SYSTEM_TIME_COMMAND.format(fmt));
1017
}
1118

12-
// command: 'hideKeyboard'
13-
// payloadParams: { optional: ['strategy', 'key', 'keyCode', 'keyName'] }
19+
// ─── File operations ─────────────────────────────────────────────────────────
1420

15-
// command: 'isKeyboardShown'
21+
const PUSH_FILE_COMMAND = pwsh$ /* ps1 */ `
22+
$path = ${0}
23+
$parentDir = [IO.Path]::GetDirectoryName($path)
24+
if ($parentDir) { [IO.Directory]::CreateDirectory($parentDir) | Out-Null }
25+
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String(${1}))
26+
`;
1627

17-
// command: 'pushFile'
18-
// payloadParams: { required: ['path', 'data'] }
28+
export async function pushFile(this: NovaWindowsDriver, path: string, data: string): Promise<void> {
29+
this.assertFeatureEnabled(MODIFY_FS_FEATURE);
30+
if (!path) {throw new errors.InvalidArgumentError("'path' must be provided.");}
31+
if (!data) {throw new errors.InvalidArgumentError("'data' must be provided.");}
32+
await this.sendPowerShellCommand(
33+
PUSH_FILE_COMMAND.format(new PSString(path).toString(), new PSString(data).toString())
34+
);
35+
}
1936

20-
// command: 'pullFile'
21-
// payloadParams: { required: ['path'] }
37+
const PULL_FILE_COMMAND = pwsh$ /* ps1 */ `[Convert]::ToBase64String([IO.File]::ReadAllBytes(${0}))`;
2238

23-
// command: 'pullFolder'
24-
// payloadParams: { required: ['path'] }
39+
export async function pullFile(this: NovaWindowsDriver, path: string): Promise<string> {
40+
this.assertFeatureEnabled(MODIFY_FS_FEATURE);
41+
if (!path) {throw new errors.InvalidArgumentError("'path' must be provided.");}
42+
return await this.sendPowerShellCommand(PULL_FILE_COMMAND.format(new PSString(path).toString()));
43+
}
2544

26-
// # APP MANAGEMENT
45+
const PULL_FOLDER_COMMAND = pwsh$ /* ps1 */ `
46+
$srcPath = ${0}
47+
$tempZip = [IO.Path]::GetTempFileName() + '.zip'
48+
try {
49+
Compress-Archive -LiteralPath $srcPath -DestinationPath $tempZip -ErrorAction Stop
50+
[Convert]::ToBase64String([IO.File]::ReadAllBytes($tempZip))
51+
} finally {
52+
if (Test-Path $tempZip) { Remove-Item $tempZip -Force }
53+
}
54+
`;
2755

28-
// command: 'activateApp'
29-
// payloadParams: { required: [['appId'], ['bundleId']], optional: ['options'] }
56+
export async function pullFolder(this: NovaWindowsDriver, path: string): Promise<string> {
57+
this.assertFeatureEnabled(MODIFY_FS_FEATURE);
58+
if (!path) {throw new errors.InvalidArgumentError("'path' must be provided.");}
59+
return await this.sendPowerShellCommand(PULL_FOLDER_COMMAND.format(new PSString(path).toString()));
60+
}
3061

31-
// command: 'removeApp'
32-
// payloadParams: { required: [['appId'], ['bundleId']], optional: ['options'] }
62+
// ─── Keyboard ────────────────────────────────────────────────────────────────
3363

34-
//command: 'terminateApp'
35-
// payloadParams: { required: [['appId'], ['bundleId']], optional: ['options'] }
64+
const HIDE_KEYBOARD_COMMAND = pwsh /* ps1 */ `
65+
$kb = Get-Process -Name 'TabTip','TextInputHost' -ErrorAction SilentlyContinue | Select-Object -First 1
66+
if ($null -eq $kb) { return }
67+
$kbEl = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst(
68+
[System.Windows.Automation.TreeScope]::Children,
69+
[System.Windows.Automation.PropertyCondition]::new(
70+
[System.Windows.Automation.AutomationElement]::ProcessIdProperty,
71+
$kb.Id
72+
)
73+
)
74+
if ($null -ne $kbEl) {
75+
try {
76+
$kbEl.GetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern).Close()
77+
} catch {
78+
Stop-Process -Id $kb.Id -Force -ErrorAction SilentlyContinue
79+
}
80+
} else {
81+
Stop-Process -Id $kb.Id -Force -ErrorAction SilentlyContinue
82+
}
83+
`;
84+
85+
export async function hideKeyboard(
86+
this: NovaWindowsDriver,
87+
_strategy?: string,
88+
_key?: string,
89+
_keyCode?: string,
90+
_keyName?: string
91+
): Promise<void> {
92+
await this.sendPowerShellCommand(HIDE_KEYBOARD_COMMAND);
93+
}
94+
95+
const IS_KEYBOARD_SHOWN_COMMAND = pwsh /* ps1 */ `
96+
$kb = Get-Process -Name 'TabTip','TextInputHost' -ErrorAction SilentlyContinue | Select-Object -First 1
97+
if ($null -eq $kb) { Write-Output 'false'; return }
98+
$kbEl = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst(
99+
[System.Windows.Automation.TreeScope]::Children,
100+
[System.Windows.Automation.PropertyCondition]::new(
101+
[System.Windows.Automation.AutomationElement]::ProcessIdProperty,
102+
$kb.Id
103+
)
104+
)
105+
if ($null -eq $kbEl) { Write-Output 'false'; return }
106+
if ($kbEl.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::IsOffscreenProperty)) {
107+
Write-Output 'false'
108+
} else {
109+
Write-Output 'true'
110+
}
111+
`;
112+
113+
export async function isKeyboardShown(this: NovaWindowsDriver): Promise<boolean> {
114+
const result = await this.sendPowerShellCommand(IS_KEYBOARD_SHOWN_COMMAND);
115+
return result.trim().toLowerCase() === 'true';
116+
}
117+
118+
// ─── App management ──────────────────────────────────────────────────────────
119+
120+
export async function activateApp(
121+
this: NovaWindowsDriver,
122+
appId: string,
123+
_options?: Record<string, unknown>
124+
): Promise<void> {
125+
if (!appId) {throw new errors.InvalidArgumentError("'appId' or 'bundleId' must be provided.");}
126+
127+
const isUwp = isUwpAppId(appId);
128+
if (isUwp) {
129+
await this.changeRootElement(appId);
130+
return;
131+
}
132+
133+
const normalizedPath = normalize(appId);
134+
const parts = normalizedPath.toLowerCase().split('\\').flatMap((x) => x.split('/'));
135+
const executable = parts[parts.length - 1];
136+
const processName = (executable.endsWith('.exe') ? executable.slice(0, -4) : executable).replace(/'/g, "''");
137+
138+
const pidResult = await this.sendPowerShellCommand(
139+
/* ps1 */ `(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Sort-Object StartTime -Descending | Select-Object -First 1).Id`
140+
);
141+
const existingPid = Number(pidResult.trim());
36142

37-
// command: 'isAppInstalled'
38-
// payloadParams: { required: [['appId'], ['bundleId']] }
143+
if (!isNaN(existingPid) && existingPid > 0) {
144+
const handleResult = await this.sendPowerShellCommand(
145+
/* ps1 */ `(Get-Process -Id ${existingPid} -ErrorAction SilentlyContinue).MainWindowHandle`
146+
);
147+
const handle = Number(handleResult.trim());
148+
if (!isNaN(handle) && handle > 0) {
149+
await this.changeRootElement(handle);
150+
return;
151+
}
152+
await this.attachToApplicationWindow([existingPid]);
153+
return;
154+
}
155+
156+
await this.changeRootElement(appId);
157+
}
158+
159+
export async function terminateApp(
160+
this: NovaWindowsDriver,
161+
appId: string,
162+
_options?: Record<string, unknown>
163+
): Promise<boolean> {
164+
if (!appId) {throw new errors.InvalidArgumentError("'appId' or 'bundleId' must be provided.");}
165+
166+
const isUwp = isUwpAppId(appId);
167+
168+
let killed: boolean;
169+
if (isUwp) {
170+
const safeFamily = new PSString(appId.split('!')[0]).toString();
171+
const checkResult = await this.sendPowerShellCommand(
172+
/* ps1 */ `
173+
$pkg = Get-AppxPackage | Where-Object { $_.PackageFamilyName -eq ${safeFamily} }
174+
if ($null -eq $pkg) { Write-Output 'none'; return }
175+
$procs = Get-Process | Where-Object { $_.Path -like ($pkg.InstallLocation + '\\*') }
176+
if (@($procs).Count -eq 0) { Write-Output 'none' } else { ($procs | Select-Object -ExpandProperty Id) -join ',' }
177+
`
178+
);
179+
const pids = checkResult.trim();
180+
if (pids === 'none' || pids === '') {
181+
await this.sendPowerShellCommand(/* ps1 */ `$rootElement = $null`).catch(() => {});
182+
return false;
183+
}
184+
await this.sendPowerShellCommand(
185+
/* ps1 */ `Stop-Process -Id ${pids} -Force -ErrorAction SilentlyContinue`
186+
);
187+
188+
const deadline = Date.now() + TERMINATE_TIMEOUT_MS;
189+
killed = false;
190+
while (Date.now() < deadline) {
191+
await sleep(TERMINATE_POLL_INTERVAL_MS);
192+
const stillRunning = await this.sendPowerShellCommand(
193+
/* ps1 */ `
194+
$pkg = Get-AppxPackage | Where-Object { $_.PackageFamilyName -eq ${safeFamily} }
195+
if ($null -eq $pkg) { 'false' } else { ($null -ne (Get-Process | Where-Object { $_.Path -like ($pkg.InstallLocation + '\\*') } | Select-Object -First 1)).ToString().ToLower() }
196+
`
197+
);
198+
if (stillRunning.trim().toLowerCase() !== 'true') {
199+
killed = true;
200+
break;
201+
}
202+
}
203+
} else {
204+
const normalizedPath = normalize(appId);
205+
const parts = normalizedPath.toLowerCase().split('\\').flatMap((x) => x.split('/'));
206+
const executable = parts[parts.length - 1];
207+
const processName = (executable.endsWith('.exe') ? executable.slice(0, -4) : executable).replace(/'/g, "''");
208+
209+
const checkResult = await this.sendPowerShellCommand(
210+
/* ps1 */ `$procs = Get-Process -Name '${processName}' -ErrorAction SilentlyContinue; if (@($procs).Count -eq 0) { Write-Output 'none' } else { ($procs | Select-Object -ExpandProperty Id) -join ',' }`
211+
);
212+
const pids = checkResult.trim();
213+
if (pids === 'none' || pids === '') {
214+
await this.sendPowerShellCommand(/* ps1 */ `$rootElement = $null`).catch(() => {});
215+
return false;
216+
}
217+
await this.sendPowerShellCommand(
218+
/* ps1 */ `Stop-Process -Id ${pids} -Force -ErrorAction SilentlyContinue`
219+
);
220+
221+
const deadline = Date.now() + TERMINATE_TIMEOUT_MS;
222+
killed = false;
223+
while (Date.now() < deadline) {
224+
await sleep(TERMINATE_POLL_INTERVAL_MS);
225+
const stillRunning = await this.sendPowerShellCommand(
226+
/* ps1 */ `(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue).Count -gt 0`
227+
);
228+
if (stillRunning.trim().toLowerCase() !== 'true') {
229+
killed = true;
230+
break;
231+
}
232+
}
233+
}
234+
235+
await this.sendPowerShellCommand(/* ps1 */ `$rootElement = $null`).catch(() => {});
236+
return killed;
237+
}
238+
239+
export async function isAppInstalled(this: NovaWindowsDriver, appId: string): Promise<boolean> {
240+
if (!appId) {throw new errors.InvalidArgumentError("'appId' or 'bundleId' must be provided.");}
241+
242+
const isUwp = isUwpAppId(appId);
243+
if (isUwp) {
244+
const safeFamily = new PSString(appId.split('!')[0]).toString();
245+
const result = await this.sendPowerShellCommand(
246+
/* ps1 */ `if (@(Get-AppxPackage | Where-Object { $_.PackageFamilyName -eq ${safeFamily} }).Count -gt 0) { 'true' } else { 'false' }`
247+
);
248+
return result.trim().toLowerCase() === 'true';
249+
}
250+
251+
const hasPathSeparator = appId.includes('/') || appId.includes('\\');
252+
if (hasPathSeparator) {
253+
const safePath = new PSString(appId).toString();
254+
const result = await this.sendPowerShellCommand(
255+
/* ps1 */ `if (Test-Path -LiteralPath ${safePath}) { 'true' } else { 'false' }`
256+
);
257+
return result.trim().toLowerCase() === 'true';
258+
}
259+
260+
// Bare process name — search PATH
261+
const safeName = new PSString(appId).toString();
262+
const result = await this.sendPowerShellCommand(
263+
/* ps1 */ `if (Get-Command -Name ${safeName} -ErrorAction SilentlyContinue) { 'true' } else { 'false' }`
264+
);
265+
return result.trim().toLowerCase() === 'true';
266+
}
39267

40268
// command: 'installApp'
41269
// payloadParams: { required: ['appPath'], optional: ['options'] }
270+
271+
// command: 'removeApp'
272+
// payloadParams: { required: [['appId'], ['bundleId']], optional: ['options'] }

lib/util.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export function assertIntegerCap(capName: string, value: number, min: number): v
3434
}
3535
}
3636

37+
export function isUwpAppId(appId: string): boolean {
38+
return appId.includes('!') && appId.includes('_') && !(appId.includes('/') || appId.includes('\\'));
39+
}
40+
3741
export function sleep(ms: number): Promise<void> {
3842
return new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
3943
}
@@ -71,4 +75,4 @@ export class DeferredStringTemplate {
7175
}
7276
return out.join('');
7377
}
74-
}
78+
}

0 commit comments

Comments
 (0)