Skip to content

Commit a8989c0

Browse files
committed
feat(tests): add unit tests and missing commands - recording, deletion and click and drag
1 parent 26db919 commit a8989c0

20 files changed

+1415
-8
lines changed

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -516,19 +516,32 @@ Position | Type | Description | Example
516516

517517
### windows: startRecordingScreen
518518

519-
To be implemented.
519+
Starts screen recording using the **bundled ffmpeg** included with the driver. There is no system PATH fallback: if the bundle is not present (e.g. driver was not installed via npm with dependencies), screen recording is not available and the driver reports a clear error.
520520

521521
### windows: stopRecordingScreen
522522

523-
To be implemented.
523+
Stops the current screen recording and returns the video (base64 or uploads to a remote path if specified).
524524

525525
### windows: deleteFile
526526

527-
To be implemented.
527+
Deletes a file on the Windows machine. Uses PowerShell `Remove-Item -Path ... -Force`. Paths containing `[`, `]`, or `?` use `-LiteralPath` for correct interpretation.
528+
529+
#### Arguments
530+
531+
Name | Type | Required | Description | Example
532+
--- | --- | --- | --- | ---
533+
path | string | yes | Absolute or relative path to the file to delete. | `C:\Temp\file.txt`
528534

529535
### windows: deleteFolder
530536

531-
To be implemented.
537+
Deletes a folder on the Windows machine. Uses PowerShell `Remove-Item -Path ... -Force` with optional `-Recurse`. Paths containing `[`, `]`, or `?` use `-LiteralPath`.
538+
539+
#### Arguments
540+
541+
Name | Type | Required | Description | Example
542+
--- | --- | --- | --- | ---
543+
path | string | yes | Absolute or relative path to the folder to delete. | `C:\Temp\MyFolder`
544+
recursive | boolean | no | If true (default), delete contents recursively. If false, only remove the folder when empty. | `true`
532545

533546
### windows: launchApp
534547

@@ -587,7 +600,23 @@ driver.ExecuteScript("windows: closeApp", new Dictionary<string, object> { { "wi
587600

588601
### windows: clickAndDrag
589602

590-
To be implemented.
603+
Performs a click-and-drag: move to the start position, press the mouse button, move to the end position over the given duration, then release. Start and end can be specified by element (center or offset) or by screen coordinates. Uses the same Windows input APIs as other pointer actions.
604+
605+
#### Arguments
606+
607+
Name | Type | Required | Description | Example
608+
--- | --- | --- | --- | ---
609+
startElementId | string | no* | Element ID for drag start. Use *or* startX/startY. | `1.2.3.4.5`
610+
startX | number | no* | X coordinate for drag start (with startY). | `100`
611+
startY | number | no* | Y coordinate for drag start (with startX). | `200`
612+
endElementId | string | no* | Element ID for drag end. Use *or* endX/endY. | `1.2.3.4.6`
613+
endX | number | no* | X coordinate for drag end (with endY). | `300`
614+
endY | number | no* | Y coordinate for drag end (with endX). | `400`
615+
modifierKeys | string or string[] | no | Keys to hold during drag: `shift`, `ctrl`, `alt`, `win`. | `["ctrl"]`
616+
durationMs | number | no | Duration of the move from start to end (default: 500). | `300`
617+
button | string | no | Mouse button: `left` (default), `middle`, `right`, `back`, `forward`. | `left`
618+
619+
\* Provide either startElementId or both startX and startY; and either endElementId or both endX and endY.
591620

592621
## Development
593622

lib/commands/extension.ts

Lines changed: 261 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { normalize } from 'node:path';
1+
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
2+
import { readFile, unlink } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join, normalize } from 'node:path';
25
import { W3C_ELEMENT_KEY, errors } from '@appium/base-driver';
36
import { Element, Rect } from '@appium/types';
47
import { NovaWindowsDriver } from '../driver';
@@ -27,6 +30,7 @@ import {
2730
pwsh
2831
} from '../powershell';
2932
import { ClickType, Enum, Key } from '../enums';
33+
import { getBundledFfmpegPath } from '../util';
3034

3135
const PLATFORM_COMMAND_PREFIX = 'windows:';
3236

@@ -58,6 +62,11 @@ const EXTENSION_COMMANDS = Object.freeze({
5862
setFocus: 'focusElement',
5963
getClipboard: 'getClipboardBase64',
6064
setClipboard: 'setClipboardFromBase64',
65+
startRecordingScreen: 'startRecordingScreen',
66+
stopRecordingScreen: 'stopRecordingScreen',
67+
deleteFile: 'deleteFile',
68+
deleteFolder: 'deleteFolder',
69+
clickAndDrag: 'executeClickAndDrag',
6170
} as const);
6271

6372
const ContentType = Object.freeze({
@@ -703,3 +712,254 @@ export async function executeScroll(this: NovaWindowsDriver, scrollArgs: {
703712
keyUp(Key.META);
704713
}
705714
}
715+
716+
export async function startRecordingScreen(this: NovaWindowsDriver, args?: {
717+
outputPath?: string,
718+
timeLimit?: number,
719+
videoSize?: string,
720+
videoFps?: number,
721+
forceRestart?: boolean,
722+
}): Promise<void> {
723+
const {
724+
outputPath = join(tmpdir(), `novawindows-recording-${Date.now()}.mp4`),
725+
timeLimit = 180,
726+
videoSize,
727+
videoFps = 15,
728+
forceRestart = false,
729+
} = args ?? {};
730+
731+
if (this.recordingProcess && !forceRestart) {
732+
throw new errors.InvalidArgumentError('Screen recording is already in progress. Use forceRestart to start a new recording.');
733+
}
734+
735+
if (this.recordingProcess && forceRestart) {
736+
const oldProc = this.recordingProcess;
737+
this.recordingProcess = undefined;
738+
this.recordingOutputPath = undefined;
739+
oldProc.stdin?.write('q');
740+
try {
741+
await new Promise<void>((resolve) => {
742+
oldProc.on('exit', () => resolve());
743+
setTimeout(() => {
744+
oldProc.kill('SIGKILL');
745+
resolve();
746+
}, 3000);
747+
});
748+
} catch {
749+
oldProc.kill('SIGKILL');
750+
}
751+
}
752+
753+
const ffmpegPath = getBundledFfmpegPath();
754+
if (!ffmpegPath) {
755+
throw new errors.UnknownError(
756+
'Screen recording is not available: the bundled ffmpeg is missing. Reinstall the driver.'
757+
);
758+
}
759+
760+
const ffmpegArgs = [
761+
'-f', 'gdigrab',
762+
'-framerate', String(videoFps),
763+
'-i', 'desktop',
764+
'-t', String(timeLimit),
765+
'-c:v', 'libx264',
766+
'-preset', 'ultrafast',
767+
'-y',
768+
outputPath,
769+
];
770+
if (videoSize) {
771+
const sizeIdx = ffmpegArgs.indexOf('-i');
772+
ffmpegArgs.splice(sizeIdx, 0, '-video_size', videoSize);
773+
}
774+
775+
const proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
776+
proc.on('error', (err) => {
777+
this.log.error(
778+
`Screen recording failed: ${err.message}. The bundled ffmpeg may be missing or invalid; try reinstalling the driver.`
779+
);
780+
});
781+
proc.stderr?.on('data', () => { /* suppress ffmpeg progress output */ });
782+
783+
this.recordingProcess = proc as ChildProcessWithoutNullStreams;
784+
this.recordingOutputPath = outputPath;
785+
}
786+
787+
export async function stopRecordingScreen(this: NovaWindowsDriver, args?: { remotePath?: string }): Promise<string> {
788+
const { remotePath } = args ?? {};
789+
790+
if (!this.recordingProcess || !this.recordingOutputPath) {
791+
throw new errors.InvalidArgumentError('No screen recording in progress.');
792+
}
793+
794+
const proc = this.recordingProcess;
795+
const outputPath = this.recordingOutputPath;
796+
this.recordingProcess = undefined;
797+
this.recordingOutputPath = undefined;
798+
799+
proc.stdin?.write('q');
800+
801+
await new Promise<void>((resolve) => {
802+
proc.on('exit', () => resolve());
803+
setTimeout(() => resolve(), 5000);
804+
});
805+
806+
if (remotePath) {
807+
// TODO: upload to remotePath; for now return empty per Appium convention
808+
try {
809+
await unlink(outputPath);
810+
} catch {
811+
/* ignore */
812+
}
813+
return '';
814+
}
815+
816+
try {
817+
const buffer = await readFile(outputPath);
818+
await unlink(outputPath);
819+
return buffer.toString('base64');
820+
} catch (err) {
821+
throw new errors.UnknownError(`Failed to read recording: ${(err as Error).message}`);
822+
}
823+
}
824+
825+
export async function deleteFile(this: NovaWindowsDriver, args: { path: string }): Promise<void> {
826+
if (!args || typeof args !== 'object' || !args.path) {
827+
throw new errors.InvalidArgumentError("'path' must be provided.");
828+
}
829+
const escapedPath = args.path.replace(/'/g, "''");
830+
const useLiteralPath = /[\[\]?]/.test(args.path);
831+
const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`;
832+
await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force -ErrorAction Stop`);
833+
}
834+
835+
export async function deleteFolder(this: NovaWindowsDriver, args: { path: string, recursive?: boolean }): Promise<void> {
836+
if (!args || typeof args !== 'object' || !args.path) {
837+
throw new errors.InvalidArgumentError("'path' must be provided.");
838+
}
839+
const { path: pathArg, recursive = true } = args;
840+
const escapedPath = pathArg.replace(/'/g, "''");
841+
const useLiteralPath = /[\[\]?]/.test(pathArg);
842+
const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`;
843+
const recurseFlag = recursive ? ' -Recurse' : '';
844+
await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force${recurseFlag} -ErrorAction Stop`);
845+
}
846+
847+
export async function executeClickAndDrag(this: NovaWindowsDriver, dragArgs: {
848+
startElementId?: string,
849+
startX?: number,
850+
startY?: number,
851+
endElementId?: string,
852+
endX?: number,
853+
endY?: number,
854+
modifierKeys?: ('shift' | 'ctrl' | 'alt' | 'win') | ('shift' | 'ctrl' | 'alt' | 'win')[],
855+
durationMs?: number,
856+
button?: ClickType,
857+
}) {
858+
const {
859+
startElementId,
860+
startX, startY,
861+
endElementId,
862+
endX, endY,
863+
modifierKeys = [],
864+
durationMs = 500,
865+
button = ClickType.LEFT,
866+
} = dragArgs ?? {};
867+
868+
if ((startX != null) !== (startY != null)) {
869+
throw new errors.InvalidArgumentError('Both startX and startY must be provided if either is set.');
870+
}
871+
872+
if ((endX != null) !== (endY != null)) {
873+
throw new errors.InvalidArgumentError('Both endX and endY must be provided if either is set.');
874+
}
875+
876+
const processesModifierKeys = Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys];
877+
const clickTypeToButtonMapping: { [key in ClickType]: number } = {
878+
[ClickType.LEFT]: 0,
879+
[ClickType.MIDDLE]: 1,
880+
[ClickType.RIGHT]: 2,
881+
[ClickType.BACK]: 3,
882+
[ClickType.FORWARD]: 4,
883+
};
884+
const mouseButton = clickTypeToButtonMapping[button];
885+
886+
let startPos: [number, number];
887+
if (startElementId) {
888+
if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(startElementId).toString()}`)) {
889+
const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(startElementId.split('.').map(Number)));
890+
const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand());
891+
892+
if (elId.trim() === '') {
893+
throw new errors.NoSuchElementError();
894+
}
895+
}
896+
897+
const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(startElementId).buildGetElementRectCommand());
898+
const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect;
899+
startPos = [
900+
rect.x + (startX ?? rect.width / 2),
901+
rect.y + (startY ?? rect.height / 2)
902+
];
903+
} else {
904+
if (startX == null || startY == null) {
905+
throw new errors.InvalidArgumentError('Either startElementId or startX and startY must be provided.');
906+
}
907+
startPos = [startX, startY];
908+
}
909+
910+
let endPos: [number, number];
911+
if (endElementId) {
912+
if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(endElementId).toString()}`)) {
913+
const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(endElementId.split('.').map(Number)));
914+
const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand());
915+
916+
if (elId.trim() === '') {
917+
throw new errors.NoSuchElementError();
918+
}
919+
}
920+
921+
const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(endElementId).buildGetElementRectCommand());
922+
const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect;
923+
endPos = [
924+
rect.x + (endX ?? rect.width / 2),
925+
rect.y + (endY ?? rect.height / 2)
926+
];
927+
} else {
928+
if (endX == null || endY == null) {
929+
throw new errors.InvalidArgumentError('Either endElementId or endX and endY must be provided.');
930+
}
931+
endPos = [endX, endY];
932+
}
933+
934+
await mouseMoveAbsolute(startPos[0], startPos[1], 0);
935+
936+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) {
937+
keyDown(Key.CONTROL);
938+
}
939+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) {
940+
keyDown(Key.ALT);
941+
}
942+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) {
943+
keyDown(Key.SHIFT);
944+
}
945+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) {
946+
keyDown(Key.META);
947+
}
948+
949+
mouseDown(mouseButton);
950+
await mouseMoveAbsolute(endPos[0], endPos[1], durationMs, this.caps.smoothPointerMove);
951+
mouseUp(mouseButton);
952+
953+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) {
954+
keyUp(Key.CONTROL);
955+
}
956+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) {
957+
keyUp(Key.ALT);
958+
}
959+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) {
960+
keyUp(Key.SHIFT);
961+
}
962+
if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) {
963+
keyUp(Key.META);
964+
}
965+
}

lib/driver.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export class NovaWindowsDriver extends BaseDriver<NovaWindowsDriverConstraints,
6666
meta: false,
6767
shift: false,
6868
};
69+
recordingProcess?: ChildProcessWithoutNullStreams;
70+
recordingOutputPath?: string;
6971

7072
constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) {
7173
super(opts, shouldValidateCaps);

lib/util.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { errors } from '@appium/base-driver';
22

3+
/**
4+
* Resolves the path to the bundled ffmpeg binary from the ffmpeg-static package.
5+
* Used by startRecordingScreen; no system PATH fallback.
6+
*/
7+
export function getBundledFfmpegPath(): string | null {
8+
try {
9+
// eslint-disable-next-line @typescript-eslint/no-require-imports
10+
const mod = require('ffmpeg-static') as string | { default?: string } | undefined;
11+
const path = typeof mod === 'string' ? mod : mod?.default;
12+
return typeof path === 'string' && path.length > 0 ? path : null;
13+
} catch {
14+
return null;
15+
}
16+
}
17+
318
const SupportedEasingFunctions = Object.freeze([
419
'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out',
520
]);

0 commit comments

Comments
 (0)