diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b0e746..4ae7014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,12 @@ on: - main - develop +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + jobs: build: runs-on: ubuntu-latest @@ -17,6 +23,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24.x + registry-url: 'https://registry.npmjs.org' - run: npm install --no-package-lock name: Install dependencies - run: npm run build diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..465a41f --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,25 @@ +name: Unit Tests + +on: + pull_request: + branches: + - main + - develop + +jobs: + test: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dev dependencies + run: npm install --no-package-lock + + - name: Run unit tests + run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adcfa9..9b1e745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,80 @@ +## [1.3.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.0...v1.3.1) (2026-03-09) + +### Bug Fixes + +* add stderr encoding for PowerShell session ([a233063](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a233063b4509e41c047fcc3603b29b944c5ac374)) +* fixed incorrect $pattern variable reference ([a0afceb](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a0afceb7e682219b5e82759c52e20c59bff1225f)) +* fixed not being able to attach to slow-starting classic apps on session creation ([f25b000](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/f25b000533be1ef2a7c0bc350bf62a3cd1b60a45)) + +## [1.3.0](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.2.0...v1.3.0) (2026-03-06) + +### Features + +* **commands:** add extra W3C commands ([57c654a](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/57c654a1e1e43c8a5d31ed8103aba338883efaa9)) +* **commands:** add support for close app and launch app ([26db919](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/26db919c17ce74ff3c5ef2776544affecb32e2fc)) +* **commands:** implement waitForAppLaunch and forceQuit ([6cce956](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6cce9565ce51b9f0e354b14819f41a6fc39ffc50)) +* **tests:** add unit tests and missing commands - recording, deletion and click and drag ([a8989c0](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a8989c06816b3f9b5b5de82d85895106ab062aca)) + +### Bug Fixes + +* Bind commands to this instance (not prototype) so each driver instance uses its own powershell session ([#56](https://github.com/AutomateThePlanet/appium-novawindows-driver/issues/56)) [skip ci] ([6dc2125](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6dc2125c505b392f100036d532326202c0a9c8d4)) +* **capability:** fix post run script ([97b57af](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/97b57af2fe05803ecb66548b8a32202fbe9a45e6)) +* **commands:** add allow-insecure check for fs operations ([4662035](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/466203585796fe08fdb4b119991363246cb00dab)) +* **commands:** match closeApp and launchApp implementation with appium windows driver ([073c566](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/073c566edb3528380196640eb33ee803b4fe2029)) +* fix bugs and implemented end to end tests ([47efa4c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/47efa4cf00fbac2e15e07e572cdf3e4453ec1020)) +* lint ([fb6ebc8](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/fb6ebc83b1ed5c0fd0c5230d2948d4f5cb156b17)) +* **lint:** lint ([acf7271](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/acf727179dfe1380d42a33a6d38f5175b22cb90d)) +* **recorder:** fix screen recording ([9da1025](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/9da1025c994cb0c3221119690f01a0584b3cf333)) +* **recorder:** validate outputPath before rimraf ([8aa49dd](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/8aa49dd27b2b9bb54329efa0555bb5ef21dc36b5)) + +### Miscellaneous Chores + +* **release:** 1.3.0 [skip ci] ([c29b822](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/c29b8223fc6f0435363d6207af0ab1185b811b60)) + +## [1.3.0](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.2.0...v1.3.0) (2026-03-06) + +### Features + +* **commands:** add extra W3C commands ([57c654a](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/57c654a1e1e43c8a5d31ed8103aba338883efaa9)) +* **commands:** add support for close app and launch app ([26db919](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/26db919c17ce74ff3c5ef2776544affecb32e2fc)) +* **commands:** implement waitForAppLaunch and forceQuit ([6cce956](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6cce9565ce51b9f0e354b14819f41a6fc39ffc50)) +* **tests:** add unit tests and missing commands - recording, deletion and click and drag ([a8989c0](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a8989c06816b3f9b5b5de82d85895106ab062aca)) + +### Bug Fixes + +* Bind commands to this instance (not prototype) so each driver instance uses its own powershell session ([#56](https://github.com/AutomateThePlanet/appium-novawindows-driver/issues/56)) [skip ci] ([6dc2125](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6dc2125c505b392f100036d532326202c0a9c8d4)) +* **capability:** fix post run script ([97b57af](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/97b57af2fe05803ecb66548b8a32202fbe9a45e6)) +* **commands:** add allow-insecure check for fs operations ([4662035](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/466203585796fe08fdb4b119991363246cb00dab)) +* **commands:** match closeApp and launchApp implementation with appium windows driver ([073c566](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/073c566edb3528380196640eb33ee803b4fe2029)) +* fix bugs and implemented end to end tests ([47efa4c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/47efa4cf00fbac2e15e07e572cdf3e4453ec1020)) +* lint ([fb6ebc8](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/fb6ebc83b1ed5c0fd0c5230d2948d4f5cb156b17)) +* **lint:** lint ([acf7271](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/acf727179dfe1380d42a33a6d38f5175b22cb90d)) +* **recorder:** fix screen recording ([9da1025](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/9da1025c994cb0c3221119690f01a0584b3cf333)) +* **recorder:** validate outputPath before rimraf ([8aa49dd](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/8aa49dd27b2b9bb54329efa0555bb5ef21dc36b5)) + +## [1.2.0](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.1.0...v1.2.0) (2026-01-09) + +### Features + +* add "none" session option to start without attaching to any element ([22586a2](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/22586a237f20e975adee25c13fba8c649420574d)) +* add appWorkingDir, prerun, postrun, and isolatedScriptExecution capabilities ([5a581ae](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/5a581ae7ae1e1a013cb8e332454f70762f8749c7)) + +### Bug Fixes + +* allow elementId with optional x/y offsets for click/hover ([2d01246](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/2d01246e009e2c7fd67165fc1d313446870021d3)) +* **deps:** downgrade appium peer dependency to 3.0.0-rc.2 ([98262d2](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/98262d297268cf40259946e4a52038103618f3b4)) +* make modifierKeys case-insensitive ([7a05300](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/7a05300ef4a0792a9c1160dfab55537c96967f08)) +* update ESLint config ([2e08f8d](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/2e08f8d5a1df9bf277b2c521584dddb5b0935e72)) +* version bump ([a872a23](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a872a23fec5f10f692b9c61ba7f8d671f360211f)) + +### Miscellaneous Chores + +* add extra logging ([5da452f](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/5da452fa71608d3f52a92c7ea6f82a78ff3139a6)) +* bump peerDependency appium to ^3.1.0 ([cdee0ca](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/cdee0ca44a1423312351449b3227035976ba396f)) +* configure semantic-release branches for stable and preview releases ([a4a1fa2](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a4a1fa2b0b20c4494919699e8d307793cf18dc04)) +* remove unnecessary ESLint ignore comments ([4c70038](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/4c7003809c6b6668315ed7e036b5ee6cf3595e51)) +* upgrade dependencies and devDependencies to latest versions ([4fd016c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/4fd016c5adc091305974b3a41c22423cadf6e3ab)) + ## [1.1.0](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.0.1...v1.1.0) (2025-08-06) ### Features diff --git a/README.md b/README.md index 84d80f9..8b22e9b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ 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`. +webviewDevtoolsPort | The local port number to use for devtools communication. By default the first free port from 10900..11000 range is selected. Consider setting the custom value if you are running parallel tests. 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. @@ -516,31 +517,78 @@ Position | Type | Description | Example ### windows: startRecordingScreen -To be implemented. +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. ### windows: stopRecordingScreen -To be implemented. +Stops the current screen recording and returns the video (base64 or uploads to a remote path if specified). ### windows: deleteFile -To be implemented. +Deletes a file on the Windows machine. Uses PowerShell `Remove-Item -Path ... -Force`. Paths containing `[`, `]`, or `?` use `-LiteralPath` for correct interpretation. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +path | string | yes | Absolute or relative path to the file to delete. | `C:\Temp\file.txt` ### windows: deleteFolder -To be implemented. +Deletes a folder on the Windows machine. Uses PowerShell `Remove-Item -Path ... -Force` with optional `-Recurse`. Paths containing `[`, `]`, or `?` use `-LiteralPath`. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +path | string | yes | Absolute or relative path to the folder to delete. | `C:\Temp\MyFolder` +recursive | boolean | no | If true (default), delete contents recursively. If false, only remove the folder when empty. | `true` ### windows: launchApp -To be implemented. +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`. + +This command takes no arguments. + +#### Example + +```javascript +// Re-launch the app set in the session capability +await driver.executeScript('windows: launchApp', []); +``` ### windows: closeApp -To be implemented. +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. + +This command takes no arguments. + +#### Example + +```javascript +// Close the current app window +await driver.executeScript('windows: closeApp', []); +``` ### windows: clickAndDrag -To be implemented. +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. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +startElementId | string | no* | Element ID for drag start. Use *or* startX/startY. | `1.2.3.4.5` +startX | number | no* | X coordinate for drag start (with startY). | `100` +startY | number | no* | Y coordinate for drag start (with startX). | `200` +endElementId | string | no* | Element ID for drag end. Use *or* endX/endY. | `1.2.3.4.6` +endX | number | no* | X coordinate for drag end (with endY). | `300` +endY | number | no* | Y coordinate for drag end (with endX). | `400` +modifierKeys | string or string[] | no | Keys to hold during drag: `shift`, `ctrl`, `alt`, `win`. | `["ctrl"]` +durationMs | number | no | Duration of the move from start to end (default: 500). | `300` +button | string | no | Mouse button: `left` (default), `middle`, `right`, `back`, `forward`. | `left` + +\* Provide either startElementId or both startX and startY; and either endElementId or both endX and endY. ## Development diff --git a/eslint.config.mjs b/eslint.config.mjs index b36e703..d82d831 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,4 +8,7 @@ import appiumConfig from '@appium/eslint-config-appium-ts'; export default defineConfig( eslint.configs.recommended, ...appiumConfig, + { + files: ['test/e2e/**/*.ts'], + }, ); diff --git a/lib/commands/actions.ts b/lib/commands/actions.ts index 6566bf4..a28bf4b 100644 --- a/lib/commands/actions.ts +++ b/lib/commands/actions.ts @@ -227,3 +227,33 @@ export async function handleKeyAction(this: NovaWindowsDriver, action: KeyAction } } } + +export async function releaseActions(this: NovaWindowsDriver): Promise { + if (this.keyboardState.shift) { + keyUp(Key.SHIFT); + keyUp(Key.R_SHIFT); + this.keyboardState.shift = false; + } + if (this.keyboardState.ctrl) { + keyUp(Key.CONTROL); + keyUp(Key.R_CONTROL); + this.keyboardState.ctrl = false; + } + if (this.keyboardState.meta) { + keyUp(Key.META); + keyUp(Key.R_META); + this.keyboardState.meta = false; + } + if (this.keyboardState.alt) { + keyUp(Key.ALT); + keyUp(Key.R_ALT); + this.keyboardState.alt = false; + } + for (const key of this.keyboardState.pressed) { + keyUp(key); + } + this.keyboardState.pressed.clear(); + mouseUp(0); + mouseUp(1); + mouseUp(2); +} diff --git a/lib/commands/app.ts b/lib/commands/app.ts index f7fe9eb..4faa938 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -17,8 +17,11 @@ import { sleep } from '../util'; import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver'; import { getWindowAllHandlesForProcessIds, + keyDown, + keyUp, trySetForegroundWindow, } from '../winapi/user32'; +import { Key } from '../enums'; const GET_PAGE_SOURCE_COMMAND = pwsh$ /* ps1 */ ` $el = ${0} @@ -55,6 +58,8 @@ const GET_SCREENSHOT_COMMAND = pwsh /* ps1 */ ` [Convert]::ToBase64String($stream.ToArray()) `; +const SLEEP_INTERVAL_MS = 500; + export async function getPageSource(this: NovaWindowsDriver): Promise { return await this.sendPowerShellCommand(GET_PAGE_SOURCE_COMMAND.format(AutomationElement.automationRoot)); } @@ -123,13 +128,30 @@ export async function setWindow(this: NovaWindowsDriver, nameOrHandle: string): return; } - 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 + this.log.info(`Failed to locate window with name '${name}'. Sleeping for ${SLEEP_INTERVAL_MS} milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout + await sleep(SLEEP_INTERVAL_MS); // TODO: make a setting for the sleep timeout } throw new errors.NoSuchWindowError(`No window was found with name or handle '${nameOrHandle}'.`); } +export async function closeApp(this: NovaWindowsDriver): Promise { + const result = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand()); + const elementId = result.split('\n').map((id) => id.trim()).filter(Boolean)[0]; + if (!elementId) { + throw new errors.NoSuchWindowError('No active app window is found for this session.'); + } + await this.sendPowerShellCommand(new FoundAutomationElement(elementId).buildCloseCommand()); + await this.sendPowerShellCommand(/* ps1 */ `$rootElement = $null`); +} + +export async function launchApp(this: NovaWindowsDriver): Promise { + if (!this.caps.app || ['root', 'none'].includes(this.caps.app.toLowerCase())) { + throw new errors.InvalidArgumentError('No app capability is set for this session.'); + } + await this.changeRootElement(this.caps.app); +} + export async function changeRootElement(this: NovaWindowsDriver, path: string): Promise export async function changeRootElement(this: NovaWindowsDriver, nativeWindowHandle: number): Promise export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWindowHandle: string | number): Promise { @@ -152,7 +174,7 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin 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 + await sleep((this.caps['ms:waitForAppLaunch'] ?? 0) * 1000 || SLEEP_INTERVAL_MS); 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); @@ -165,14 +187,14 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin // noop } - this.log.info(`Failed to locate window of the app. 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 + this.log.info(`Failed to locate window of the app. Sleeping for ${SLEEP_INTERVAL_MS} milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout + await sleep(SLEEP_INTERVAL_MS); // 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 + await sleep((this.caps['ms:waitForAppLaunch'] ?? 0) * 1000 || 500); for (let i = 1; i <= 20; i++) { try { const breadcrumbs = normalizedPath.toLowerCase().split('\\').flatMap((x) => x.split('/')); @@ -190,38 +212,115 @@ export async function changeRootElement(this: NovaWindowsDriver, pathOrNativeWin } } - this.log.info(`Failed to locate window of the app. 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 + this.log.info(`Failed to locate window of the app. Sleeping for ${SLEEP_INTERVAL_MS} milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout + await sleep(SLEEP_INTERVAL_MS); // TODO: make a setting for the sleep timeout } } throw new errors.UnknownError('Failed to locate window of the app.'); } -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(', ')}`); +export async function back(this: NovaWindowsDriver): Promise { + const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim(); + if (!elementId) { + throw new errors.NoSuchWindowError('No active window found for this session.'); + } + keyDown(Key.ALT); + keyDown(Key.LEFT); + keyUp(Key.LEFT); + keyUp(Key.ALT); +} - if (nativeWindowHandles.length !== 0) { - let elementId = ''; - for (let i = 1; i <= 20; i++) { - elementId = await this.sendPowerShellCommand(AutomationElement.rootElement.findFirst(TreeScope.CHILDREN, new PropertyCondition(Property.NATIVE_WINDOW_HANDLE, new PSInt32(nativeWindowHandles[0]))).buildCommand()); - if (elementId) { - break; - } - this.log.info(`The window with handle 0x${nativeWindowHandles[0].toString(16).padStart(8, '0')} is not yet available in the UI Automation tree. 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 +export async function forward(this: NovaWindowsDriver): Promise { + const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim(); + if (!elementId) { + throw new errors.NoSuchWindowError('No active window found for this session.'); + } + keyDown(Key.ALT); + keyDown(Key.RIGHT); + keyUp(Key.RIGHT); + keyUp(Key.ALT); +} + +export async function title(this: NovaWindowsDriver): Promise { + const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim(); + if (!elementId) { + throw new errors.NoSuchWindowError('No active window found for this session.'); + } + return await this.sendPowerShellCommand( + AutomationElement.automationRoot.buildGetPropertyCommand(Property.NAME) + ); +} + +export async function setWindowRect( + this: NovaWindowsDriver, + x: number | null, + y: number | null, + width: number | null, + height: number | null +): Promise { + if (width !== null && width < 0) { + throw new errors.InvalidArgumentError('width must be a non-negative integer.'); + } + if (height !== null && height < 0) { + throw new errors.InvalidArgumentError('height must be a non-negative integer.'); + } + + const elementId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim(); + if (!elementId) { + throw new errors.NoSuchWindowError('No active window found for this session.'); + } + + const el = new FoundAutomationElement(elementId); + if (x !== null && y !== null) { + await this.sendPowerShellCommand(el.buildMoveCommand(x, y)); + } + if (width !== null && height !== null) { + await this.sendPowerShellCommand(el.buildResizeCommand(width, height)); + } + + return await this.getWindowRect(); +} + +export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, timeout: number): Promise { + const start = Date.now(); + let attempts = 0; + + while (Date.now() - start < timeout) { + const handles = getWindowAllHandlesForProcessIds([pid]); + + if (handles.length > 0) { + return handles[handles.length - 1]; } - await this.sendPowerShellCommand(/* ps1 */ `$rootElement = ${new FoundAutomationElement(elementId).buildCommand()}`); - if ((await this.sendPowerShellCommand(/* ps1 */ `$null -ne $rootElement`)).toLowerCase() === 'true') { - const nativeWindowHandle = Number(await this.sendPowerShellCommand(AutomationElement.automationRoot.buildGetPropertyCommand(Property.NATIVE_WINDOW_HANDLE))); - if (!trySetForegroundWindow(nativeWindowHandle)) { - await this.focusElement({ - [W3C_ELEMENT_KEY]: elementId, - } satisfies Element); - }; - return; + this.log.debug(`Waiting for the process window to appear... (${++attempts}/${Math.floor(timeout / SLEEP_INTERVAL_MS)})`); + await sleep(SLEEP_INTERVAL_MS); + } + + throw new errors.TimeoutError('Timed out waiting for window.'); +} + +export async function attachToApplicationWindow(this: NovaWindowsDriver, processIds: number[]): Promise { + const nativeWindowHandle = await waitForNewWindow.call(this, processIds[0], this.caps['ms:waitForAppLaunch'] ?? SLEEP_INTERVAL_MS * 20); + + let elementId = ''; + for (let i = 1; i <= 20; i++) { + elementId = await this.sendPowerShellCommand(AutomationElement.rootElement.findFirst(TreeScope.CHILDREN, new PropertyCondition(Property.NATIVE_WINDOW_HANDLE, new PSInt32(nativeWindowHandle))).buildCommand()); + if (elementId) { + break; } + this.log.info(`The window with handle 0x${nativeWindowHandle.toString(16).padStart(8, '0')} is not yet available in the UI Automation tree. Sleeping for ${SLEEP_INTERVAL_MS} milliseconds and retrying... (${i}/20)`); // TODO: make a setting for the number of retries or timeout + await sleep(SLEEP_INTERVAL_MS); // TODO: make a setting for the sleep timeout + } + + await this.sendPowerShellCommand(/* ps1 */ `$rootElement = ${new FoundAutomationElement(elementId).buildCommand()}`); + if ((await this.sendPowerShellCommand(/* ps1 */ `$null -ne $rootElement`)).toLowerCase() === 'true') { + const nativeWindowHandle = Number(await this.sendPowerShellCommand(AutomationElement.automationRoot.buildGetPropertyCommand(Property.NATIVE_WINDOW_HANDLE))); + if (!trySetForegroundWindow(nativeWindowHandle)) { + await this.focusElement({ + [W3C_ELEMENT_KEY]: elementId, + } satisfies Element); + }; + return; } } diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts new file mode 100644 index 0000000..53ed926 --- /dev/null +++ b/lib/commands/contexts.ts @@ -0,0 +1,302 @@ +import { Chromedriver, ChromedriverOpts } from 'appium-chromedriver'; +import { fs, node, system, tempDir, zip } from '@appium/support'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { sleep } from '../util'; +import { NovaWindowsDriver } from '../driver'; +import { errors } from '@appium/base-driver'; + +const NATIVE_APP = 'NATIVE_APP'; +const WEBVIEW = 'WEBVIEW'; +const WEBVIEW_BASE = `${WEBVIEW}_`; + +const MODULE_NAME = 'appium-novawindows-driver'; + +export async function getCurrentContext(this: NovaWindowsDriver): Promise { + return this.currentContext ??= NATIVE_APP; +} + +export async function setContext(this: NovaWindowsDriver, name?: string | null): Promise { + if (!name || name === NATIVE_APP) { + this.chromedriver?.stop(); + this.chromedriver = null; + this.jwpProxyActive = false; + this.proxyReqRes = null; + this.proxyCommand = null; + this.currentContext = NATIVE_APP; + return; + } + + const webViewDetails = await this.getWebViewDetails(); + + if (!(webViewDetails.pages ?? []).map((page) => page.id).includes(name.replace(WEBVIEW_BASE, ''))) { + throw new errors.InvalidArgumentError(`Web view not found: ${name}`); + } + + const browser = webViewDetails.info?.Browser ?? ''; + const match = browser.match(/(Chrome|Edg)\/([\d.]+)/); + + if (!match?.[1] || (match[1] !== 'Edg' && match[1] !== 'Chrome')) { + throw new errors.InvalidArgumentError(`Unsupported browser type: ${match?.[1]}`); + } + + const browserType = match[1] === 'Edg' ? 'Edge' : 'Chrome'; + const browserVersion = match?.[2] ?? ''; + + const DRIVER_VERSION_REGEX = /^\d+(\.\d+){3}$/; + if (!DRIVER_VERSION_REGEX.test(browserVersion)) { + throw new errors.InvalidArgumentError(`Invalid browser version: ${browserVersion}`); + } + + this.log.debug(`Type: ${browserType}, Version: ${browserVersion}`); + + const executable: string = await getDriverExecutable.call(this, browserType, browserVersion); + + const chromedriverOpts: ChromedriverOpts & { details?: WebViewDetails } = { + executable, + details: webViewDetails, + }; + + if (this.basePath) { + chromedriverOpts.reqBasePath = this.basePath; + } + + const cd = new Chromedriver(chromedriverOpts); + this.chromedriver = cd; + + const page = webViewDetails.pages?.find((p) => p.id === name.replace(WEBVIEW_BASE, '')); + + const debuggerAddress = (page?.webSocketDebuggerUrl ?? '') + .replace('ws://', '') + .split('/')[0]; + + const options = { debuggerAddress }; + + const caps = { + 'ms:edgeOptions': options, + 'goog:chromeOptions': options, + }; + + await this.chromedriver.start(caps); + this.log.debug('Chromedriver started. Session ID:', cd.sessionId()); + + this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); + this.proxyCommand = this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy); + this.jwpProxyActive = true; +} + +export async function getContexts(this: NovaWindowsDriver): Promise { + const webViewDetails = await this.getWebViewDetails(); + return [ + NATIVE_APP, + ...(webViewDetails.pages?.map((page) => `${WEBVIEW_BASE}${page.id}`) ?? []), + ]; +} + +export interface WebViewDetails { + /** + * Web view details as returned by /json/version CDP endpoint + * @example + * { + * "Browser": "Edg/145.0.3800.97", + * "Protocol-Version": "1.3", + * "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + * "V8-Version": "14.5.40.9", + * "WebKit-Version": "537.36 (@f4c49d5241f148220b99eb7f045ac370a1694a15)", + * "webSocketDebuggerUrl": "ws://localhost:10900/devtools/browser/7039e1b9-f75d-44eb-8583-7279c107bb18" + * } + */ + info?: CDPVersionResponse; + + /** + * Web view details as returned by /json/list CDP endpoint + * @example // TODO: change example to not include Spotify / use mock data + * [ { + * "description": "", + * "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@80be69ef794ba862ff256b0b23f051cbbc32e1ed/inspector.html?ws=localhost:9222/devtools/page/21C6035BC3E0A67D0BB6AE10F4A66D4A", + * "faviconUrl": "https://xpui.app.spotify.com/favicon.ico", + * "id": "21C6035BC3E0A67D0BB6AE10F4A66D4A", + * "title": "Spotify - Web Player: Music for everyone", + * "type": "page", + * "url": "https://xpui.app.spotify.com/index.html", + * "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/21C6035BC3E0A67D0BB6AE10F4A66D4A" + * }, { + * "description": "", + * "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@80be69ef794ba862ff256b0b23f051cbbc32e1ed/inspector.html?ws=localhost:9222/devtools/page/7E7008B3C464CD91224ADF976115101F", + * "id": "7E7008B3C464CD91224ADF976115101F", + * "title": "", + * "type": "worker", + * "url": "", + * "webSocketDebuggerUrl": "ws://localhost:10900/devtools/page/7E7008B3C464CD91224ADF976115101F" + * } ] + */ + pages?: CDPListResponse; +} + +interface CDPVersionResponse { + 'Browser': string, + 'Protocol-Version': string, + 'User-Agent': string, + 'V8-Version': string, + 'WebKit-Version': string, + 'webSocketDebuggerUrl': string, +} + +interface CDPListResponseEntry { + 'description': string, + 'devtoolsFrontendUrl': string, + 'faviconUrl': string, + 'id': string, + 'title': string, + 'type': string, + 'url': string, + 'webSocketDebuggerUrl': string, +} + +type CDPListResponse = CDPListResponseEntry[]; + +export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewMs?: number): Promise { + if (!this.caps.enableWebView) { + throw new errors.InvalidArgumentError('WebView support is not enabled. Please set the "enableWebView" capability to true and try again.'); + } + + this.log.debug(`Getting a list of available webviews`); + + const waitMs = waitForWebviewMs ? Number(waitForWebviewMs) : 0; + if (waitMs) { + await sleep(waitMs); + } + + const host = 'localhost'; + const port = this.webviewDevtoolsPort; + const webViewDetails: WebViewDetails = { + info: await cdpRequest({ host, port, endpoint: '/json/version', timeout: 10000 }), + pages: await cdpRequest({ host, port, endpoint: '/json/list', timeout: 10000 }), + }; + + return webViewDetails; +} + +async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' | 'Chrome', browserVersion: `${number}.${number}.${number}.${number}`): Promise { + let driverType: string; + + if (browserType === 'Chrome') { + driverType = 'chromedriver'; + } else { + driverType = 'edgedriver'; + } + + const root = node.getModuleRootSync(MODULE_NAME, __filename); + if (!root) { + throw new errors.InvalidArgumentError(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`); + } + + const driverDir = path.join(root, driverType); + + if (!(await fs.exists(driverDir))) { + await fs.mkdir(driverDir); + } + + let downloadUrl = ''; + const fileName = browserType === 'Edge' ? 'msedgedriver.exe' : 'chromedriver.exe'; + const finalPath = path.join(driverDir, browserVersion, fileName); + + if (await fs.exists(finalPath)) { + return finalPath; + }; + + const arch = await system.arch(); + const zipFilename = `${driverType}${browserType === 'Edge' ? '_' : '-'}win${arch}.zip`; + + if (browserType === 'Chrome') { + downloadUrl = `https://storage.googleapis.com/chrome-for-testing-public/${browserVersion}/win${arch}/${zipFilename}`; + } else if (browserType === 'Edge') { + downloadUrl = `https://msedgedriver.microsoft.com/${browserVersion}/${zipFilename}`; + } + + this.log.debug(`Downloading ${browserType} driver version ${browserVersion}...`); + const tmpRoot = await tempDir.openDir(); + await downloadFile(downloadUrl, tmpRoot); + try { + await zip.extractAllTo(path.join(tmpRoot, zipFilename), tmpRoot); + const driverPath = await fs.walkDir( + tmpRoot, + true, + (itemPath, isDirectory) => !isDirectory && path.parse(itemPath).base.toLowerCase() === fileName); + + if (!driverPath) { + throw new errors.UnknownError(`The archive was unzipped properly, but did not find any ${driverType} executable.`); + } + this.log.debug(`Moving the extracted '${fileName}' to '${finalPath}'`); + await fs.mv(driverPath, finalPath, { mkdirp: true }); + } finally { + await fs.rimraf(tmpRoot); + } + return finalPath; +} + +async function cdpRequest({ host, port, endpoint, timeout }): Promise { + return new Promise((resolve, reject) => { + const options = { + hostname: host, + port, + path: endpoint, + method: 'GET', + agent: new http.Agent({ keepAlive: false }), + timeout, + }; + + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(new Error('Request timed out')); + }); + + req.end(); + }); +} + +async function downloadFile(url: string, destPath: string, timeout = 30000): Promise { + const protocol = url.startsWith('https') ? https : http; + const fileName = path.basename(new URL(url).pathname); + + const fullFilePath = path.join(destPath, fileName); + + return new Promise((resolve, reject) => { + const req = protocol.get(url, async (res) => { + if (res.statusCode !== 200) { + return reject(new Error(`Download failed: ${res.statusCode}`)); + } + + try { + const fileStream = fs.createWriteStream(fullFilePath); + await pipeline(res, fileStream); + resolve(); + } catch (err) { + await fs.unlink(fullFilePath).catch(() => { }); + reject(err); + } + }); + + req.on('error', reject); + req.setTimeout(timeout, () => { + req.destroy(); + reject(new Error(`Timeout downloading from ${url}`)); + }); + }); +} diff --git a/lib/commands/device.ts b/lib/commands/device.ts index 06268fa..e595044 100644 --- a/lib/commands/device.ts +++ b/lib/commands/device.ts @@ -4,9 +4,9 @@ import { PSString, pwsh$ } from '../powershell'; const GET_SYSTEM_TIME_COMMAND = pwsh$ /* ps1 */ `(Get-Date).ToString(${0})`; const ISO_8061_FORMAT = 'yyyy-MM-ddTHH:mm:sszzz'; -export async function getDeviceTime(this: NovaWindowsDriver, format?: string): Promise { - format = format ? new PSString(format).toString() : `'${ISO_8061_FORMAT}'`; - return await this.sendPowerShellCommand(GET_SYSTEM_TIME_COMMAND.format(format)); +export async function getDeviceTime(this: NovaWindowsDriver, _sessionId?: string, format?: string): Promise { + const fmt = format ? new PSString(format).toString() : `'${ISO_8061_FORMAT}'`; + return await this.sendPowerShellCommand(GET_SYSTEM_TIME_COMMAND.format(fmt)); } // command: 'hideKeyboard' diff --git a/lib/commands/element.ts b/lib/commands/element.ts index df59cf2..e92aced 100644 --- a/lib/commands/element.ts +++ b/lib/commands/element.ts @@ -12,8 +12,9 @@ import { PSControlType, PSString, TreeScope, + pwsh$, } from '../powershell'; -import { W3C_ELEMENT_KEY } from '@appium/base-driver'; +import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver'; import { mouseDown, mouseMoveAbsolute, mouseUp } from '../winapi/user32'; import { Key } from '../enums'; import { sleep } from '../util'; @@ -239,4 +240,27 @@ export async function click(this: NovaWindowsDriver, elementId: string): Promise if (this.caps.delayAfterClick) { await sleep(this.caps.delayAfterClick ?? 0); } +} + +const GET_ELEMENT_SCREENSHOT_COMMAND = pwsh$ /* ps1 */ ` + $element = ${0} + $rect = $element.Current.BoundingRectangle + $bitmap = New-Object Drawing.Bitmap([int32]$rect.Width, [int32]$rect.Height) + $graphics = [Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen([int32]$rect.Left, [int32]$rect.Top, 0, 0, $bitmap.Size) + $graphics.Dispose() + $stream = New-Object IO.MemoryStream + $bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + [Convert]::ToBase64String($stream.ToArray()) +`; + +export async function getElementScreenshot(this: NovaWindowsDriver, elementId: string): Promise { + const rootId = (await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand())).trim(); + if (!rootId) { + throw new errors.NoSuchWindowError('No active window found for this session.'); + } + return await this.sendPowerShellCommand( + GET_ELEMENT_SCREENSHOT_COMMAND.format(new FoundAutomationElement(elementId)) + ); } \ No newline at end of file diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index e37ea69..4794723 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -1,17 +1,10 @@ -import { W3C_ELEMENT_KEY, errors } from '@appium/base-driver'; +import { PROTOCOLS, W3C_ELEMENT_KEY, errors } from '@appium/base-driver'; import { Element, Rect } from '@appium/types'; +import { tmpdir } from 'node:os'; +import { extname, join } from 'node:path'; +import { MODIFY_FS_FEATURE, POWER_SHELL_FEATURE } from '../constants'; import { NovaWindowsDriver } from '../driver'; -import { $, sleep } from '../util'; -import { POWER_SHELL_FEATURE } from '../constants'; -import { keyDown, - keyUp, - mouseDown, - mouseMoveAbsolute, - mouseScroll, - mouseUp, - sendKeyboardEvents -} from '../winapi/user32'; -import { KeyEventFlags, VirtualKey } from '../winapi/types'; +import { ClickType, Enum, Key } from '../enums'; import { AutomationElement, AutomationElementMode, @@ -24,7 +17,18 @@ import { convertStringToCondition, pwsh } from '../powershell'; -import { ClickType, Enum, Key } from '../enums'; +import { $, sleep } from '../util'; +import { DEFAULT_EXT, ScreenRecorder, UploadOptions, uploadRecordedMedia } from './screen-recorder'; +import { KeyEventFlags, VirtualKey } from '../winapi/types'; +import { + keyDown, + keyUp, + mouseDown, + mouseMoveAbsolute, + mouseScroll, + mouseUp, + sendKeyboardEvents +} from '../winapi/user32'; const PLATFORM_COMMAND_PREFIX = 'windows:'; @@ -47,6 +51,8 @@ const EXTENSION_COMMANDS = Object.freeze({ minimize: 'patternMinimize', restore: 'patternRestore', close: 'patternClose', + closeApp: 'windowsCloseApp', + launchApp: 'windowsLaunchApp', keys: 'executeKeys', click: 'executeClick', hover: 'executeHover', @@ -54,6 +60,13 @@ const EXTENSION_COMMANDS = Object.freeze({ setFocus: 'focusElement', getClipboard: 'getClipboardBase64', setClipboard: 'setClipboardFromBase64', + startRecordingScreen: 'startRecordingScreen', + stopRecordingScreen: 'stopRecordingScreen', + deleteFile: 'deleteFile', + deleteFolder: 'deleteFolder', + clickAndDrag: 'executeClickAndDrag', + getDeviceTime: 'windowsGetDeviceTime', + getWindowElement: 'getWindowElement', } as const); const ContentType = Object.freeze({ @@ -67,7 +80,7 @@ const TREE_FILTER_COMMAND = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.Tree const TREE_SCOPE_COMMAND = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.TreeScope = ${0}; $cacheRequest.Push()`; const AUTOMATION_ELEMENT_MODE = $ /* ps1 */ `$cacheRequest.Pop(); $cacheRequest.AutomationElementMode = ${0}; $cacheRequest.Push()`; -const SET_PLAINTEXT_CLIPBOARD_FROM_BASE64 = $ /* ps1 */ `Set-Clipboard -Value [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${0}))`; +const SET_PLAINTEXT_CLIPBOARD_FROM_BASE64 = $ /* ps1 */ `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${0})))`; const GET_PLAINTEXT_CLIPBOARD_BASE64 = /* ps1 */ `[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard)))`; 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()`; @@ -106,11 +119,29 @@ export async function execute(this: NovaWindowsDriver, script: string, args: any return await this[EXTENSION_COMMANDS[script]](...args); } + if (script === 'mobile:getContexts') { + if (!this.caps.enableWebView) { + throw new errors.InvalidArgumentError('WebView support is not enabled. To use this command, enable WebView support by setting the "enableWebView" capability to true.'); + } + const { waitForWebviewMs }: { waitForWebviewMs?: number } = args[0] || {}; + const webViewDetails = await this.getWebViewDetails(waitForWebviewMs); + return [{ + id: 'NATIVE_APP', + }, ...(webViewDetails.pages ?? [])]; + } + if (script === 'powerShell') { this.assertFeatureEnabled(POWER_SHELL_FEATURE); return await this.executePowerShellScript(args[0]); } + if (this.chromedriver && this.proxyActive()) { + const endpoint = this.chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP + ? '/execute' + : '/execute/sync'; + return await this.chromedriver.jwproxy.command(endpoint, 'POST', { script, args }); + } + if (script === 'return window.name') { return await this.sendPowerShellCommand(AutomationElement.automationRoot.buildGetPropertyCommand(Property.NAME)); } @@ -137,7 +168,7 @@ export async function pushCacheRequest(this: NovaWindowsDriver, cacheRequest: Ca } if (cacheRequest.treeScope) { - const treeScope = TREE_SCOPE_REGEX.exec(cacheRequest.treeScope)?.groups?.[0]; + const treeScope = TREE_SCOPE_REGEX.exec(cacheRequest.treeScope)?.[1]; if (!treeScope || (Number(cacheRequest.treeScope) < 1 && Number(cacheRequest.treeScope) > 16)) { throw new errors.InvalidArgumentError(`Invalid value '${cacheRequest.treeScope}' passed to TreeScope for cache request.`); } @@ -146,7 +177,7 @@ export async function pushCacheRequest(this: NovaWindowsDriver, cacheRequest: Ca } if (cacheRequest.automationElementMode) { - const treeScope = AUTOMATION_ELEMENT_MODE_REGEX.exec(cacheRequest.automationElementMode)?.groups?.[0]; + const treeScope = AUTOMATION_ELEMENT_MODE_REGEX.exec(cacheRequest.automationElementMode)?.[1]; if (!treeScope || (Number(cacheRequest.automationElementMode) < 0 && Number(cacheRequest.automationElementMode) > 1)) { throw new errors.InvalidArgumentError(`Invalid value '${cacheRequest.automationElementMode}' passed to AutomationElementMode for cache request.`); @@ -224,8 +255,8 @@ export async function patternSetValue(this: NovaWindowsDriver, element: Element, } } -export async function patternGetValue(this: NovaWindowsDriver, element: Element): Promise { - await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildGetValueCommand()); +export async function patternGetValue(this: NovaWindowsDriver, element: Element): Promise { + return await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildGetValueCommand()); } export async function patternMaximize(this: NovaWindowsDriver, element: Element): Promise { @@ -244,6 +275,14 @@ export async function patternClose(this: NovaWindowsDriver, element: Element): P await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildCloseCommand()); } +export async function windowsCloseApp(this: NovaWindowsDriver): Promise { + return await this.closeApp(); +} + +export async function windowsLaunchApp(this: NovaWindowsDriver): Promise { + return await this.launchApp(); +} + export async function focusElement(this: NovaWindowsDriver, element: Element): Promise { await this.sendPowerShellCommand(new FoundAutomationElement(element[W3C_ELEMENT_KEY]).buildSetFocusCommand()); } @@ -272,9 +311,9 @@ export async function setClipboardFromBase64(this: NovaWindowsDriver, args: { co switch (contentType.toLowerCase()) { case ContentType.PLAINTEXT: - return await this.sendPowerShellCommand(SET_PLAINTEXT_CLIPBOARD_FROM_BASE64.format(args.b64Content)); + return await this.sendPowerShellCommand(SET_PLAINTEXT_CLIPBOARD_FROM_BASE64.format(`'${args.b64Content}'`)); case ContentType.IMAGE: - return await this.sendPowerShellCommand(SET_IMAGE_CLIPBOARD_FROM_BASE64.format(args.b64Content)); + return await this.sendPowerShellCommand(SET_IMAGE_CLIPBOARD_FROM_BASE64.format(`'${args.b64Content}'`)); default: throw new errors.InvalidArgumentError(`Unsupported content type '${contentType}'.`); } @@ -404,7 +443,7 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: { pos = [x!, y!]; } - const clickTypeToButtonMapping: { [key in ClickType]: number} = { + const clickTypeToButtonMapping: { [key in ClickType]: number } = { [ClickType.LEFT]: 0, [ClickType.MIDDLE]: 1, [ClickType.RIGHT]: 2, @@ -634,3 +673,240 @@ export async function executeScroll(this: NovaWindowsDriver, scrollArgs: { keyUp(Key.META); } } + +export async function startRecordingScreen(this: NovaWindowsDriver, args?: { + outputPath?: string, + timeLimit?: number, + videoFps?: number, + videoFilter?: string, + preset?: string, + captureCursor?: boolean, + captureClicks?: boolean, + audioInput?: string, + forceRestart?: boolean, +}): Promise { + const { + outputPath, + timeLimit, + videoFps: fps, + videoFilter, + preset, + captureCursor, + captureClicks, + audioInput, + forceRestart = true, + } = args ?? {}; + + if (this._screenRecorder?.isRunning()) { + this.log.debug('The screen recording is already running'); + if (!forceRestart) { + this.log.debug('Doing nothing'); + return; + } + this.log.debug('Forcing the active screen recording to stop'); + await this._screenRecorder.stop(true); + } else if (this._screenRecorder) { + this.log.debug('Clearing the recent screen recording'); + await this._screenRecorder.stop(true); + } + this._screenRecorder = null; + + if (outputPath) { + const ext = extname(outputPath).toLowerCase(); + if (ext !== `.${DEFAULT_EXT}`) { + throw new errors.InvalidArgumentError( + `outputPath must be a path to a .${DEFAULT_EXT} file, got: '${outputPath}'`, + ); + } + } + const videoPath = outputPath ?? join(tmpdir(), `novawindows-recording-${Date.now()}.${DEFAULT_EXT}`); + this._screenRecorder = new ScreenRecorder(videoPath, this.log, { + fps: fps !== undefined ? parseInt(String(fps), 10) : undefined, + timeLimit: timeLimit !== undefined ? parseInt(String(timeLimit), 10) : undefined, + preset, + captureCursor, + captureClicks, + videoFilter, + audioInput, + }); + try { + await this._screenRecorder.start(); + } catch (e) { + this._screenRecorder = null; + throw e; + } +} + +export async function stopRecordingScreen(this: NovaWindowsDriver, args?: UploadOptions): Promise { + if (!this._screenRecorder) { + this.log.debug('No screen recording has been started. Doing nothing'); + return ''; + } + + this.log.debug('Retrieving the resulting video data'); + const videoPath = await this._screenRecorder.stop(); + if (!videoPath) { + this.log.debug('No video data is found. Returning an empty string'); + return ''; + } + + const { remotePath, ...uploadOpts } = args ?? {}; + return await uploadRecordedMedia(videoPath, remotePath, uploadOpts); +} + +export async function deleteFile(this: NovaWindowsDriver, args: { path: string }): Promise { + this.assertFeatureEnabled(MODIFY_FS_FEATURE); + if (!args || typeof args !== 'object' || !args.path) { + throw new errors.InvalidArgumentError("'path' must be provided."); + } + const escapedPath = args.path.replace(/'/g, "''"); + const useLiteralPath = /[[\][]?]/.test(args.path); + const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`; + await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force -ErrorAction Stop`); +} + +export async function deleteFolder(this: NovaWindowsDriver, args: { path: string, recursive?: boolean }): Promise { + this.assertFeatureEnabled(MODIFY_FS_FEATURE); + if (!args || typeof args !== 'object' || !args.path) { + throw new errors.InvalidArgumentError("'path' must be provided."); + } + const { path: pathArg, recursive = true } = args; + const escapedPath = pathArg.replace(/'/g, "''"); + const useLiteralPath = /[[\][]?]/.test(pathArg); + const pathParam = useLiteralPath ? `-LiteralPath '${escapedPath}'` : `-Path '${escapedPath}'`; + const recurseFlag = recursive ? ' -Recurse' : ''; + await this.sendPowerShellCommand(`Remove-Item ${pathParam} -Force${recurseFlag} -ErrorAction Stop`); +} + +export async function executeClickAndDrag(this: NovaWindowsDriver, dragArgs: { + startElementId?: string, + startX?: number, + startY?: number, + endElementId?: string, + endX?: number, + endY?: number, + modifierKeys?: ('shift' | 'ctrl' | 'alt' | 'win') | ('shift' | 'ctrl' | 'alt' | 'win')[], + durationMs?: number, + button?: ClickType, +}) { + const { + startElementId, + startX, startY, + endElementId, + endX, endY, + modifierKeys = [], + durationMs = 500, + button = ClickType.LEFT, + } = dragArgs ?? {}; + + if ((startX != null) !== (startY != null)) { + throw new errors.InvalidArgumentError('Both startX and startY must be provided if either is set.'); + } + + 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]; + const clickTypeToButtonMapping: { [key in ClickType]: number } = { + [ClickType.LEFT]: 0, + [ClickType.MIDDLE]: 1, + [ClickType.RIGHT]: 2, + [ClickType.BACK]: 3, + [ClickType.FORWARD]: 4, + }; + const mouseButton = clickTypeToButtonMapping[button]; + + let startPos: [number, number]; + if (startElementId) { + if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(startElementId).toString()}`)) { + const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(startElementId.split('.').map(Number))); + const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand()); + + if (elId.trim() === '') { + throw new errors.NoSuchElementError(); + } + } + + const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(startElementId).buildGetElementRectCommand()); + const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; + startPos = [ + rect.x + (startX ?? rect.width / 2), + rect.y + (startY ?? rect.height / 2) + ]; + } else { + if (startX == null || startY == null) { + throw new errors.InvalidArgumentError('Either startElementId or startX and startY must be provided.'); + } + startPos = [startX, startY]; + } + + let endPos: [number, number]; + if (endElementId) { + if (await this.sendPowerShellCommand(/* ps1 */ `$null -eq ${new FoundAutomationElement(endElementId).toString()}`)) { + const condition = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array(endElementId.split('.').map(Number))); + const elId = await this.sendPowerShellCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, condition).buildCommand()); + + if (elId.trim() === '') { + throw new errors.NoSuchElementError(); + } + } + + const rectJson = await this.sendPowerShellCommand(new FoundAutomationElement(endElementId).buildGetElementRectCommand()); + const rect = JSON.parse(rectJson.replaceAll(/(?:infinity)/gi, 0x7FFFFFFF.toString())) as Rect; + endPos = [ + rect.x + (endX ?? rect.width / 2), + rect.y + (endY ?? rect.height / 2) + ]; + } else { + if (endX == null || endY == null) { + throw new errors.InvalidArgumentError('Either endElementId or endX and endY must be provided.'); + } + endPos = [endX, endY]; + } + + await mouseMoveAbsolute(startPos[0], startPos[1], 0); + + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { + keyDown(Key.CONTROL); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { + keyDown(Key.ALT); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { + keyDown(Key.SHIFT); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { + keyDown(Key.META); + } + + mouseDown(mouseButton); + await mouseMoveAbsolute(endPos[0], endPos[1], durationMs, this.caps.smoothPointerMove); + mouseUp(mouseButton); + + if (processesModifierKeys.some((key) => key.toLowerCase() === 'ctrl')) { + keyUp(Key.CONTROL); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'alt')) { + keyUp(Key.ALT); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'shift')) { + keyUp(Key.SHIFT); + } + if (processesModifierKeys.some((key) => key.toLowerCase() === 'win')) { + keyUp(Key.META); + } +} + +export async function windowsGetDeviceTime(this: NovaWindowsDriver, args?: { format?: string }): Promise { + return this.getDeviceTime(undefined, args?.format); +} + +export async function getWindowElement(this: NovaWindowsDriver): Promise { + const result = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand()); + const elementId = result.split('\n').map((id) => id.trim()).filter(Boolean)[0]; + if (!elementId) { + throw new errors.NoSuchWindowError('No active app window is found for this session.'); + } + return { [W3C_ELEMENT_KEY]: elementId }; +} diff --git a/lib/commands/functions.ts b/lib/commands/functions.ts index 04ec8fa..53aa278 100644 --- a/lib/commands/functions.ts +++ b/lib/commands/functions.ts @@ -152,18 +152,18 @@ export const PAGE_SOURCE = pwsh /* ps1 */ ` $pattern = $null if ($element.TryGetCurrentPattern([WindowPattern]::Pattern, [ref]$pattern)) { - $newXmlElement.SetAttribute("CanMaximize", $windowPattern.Current.CanMaximize) - $newXmlElement.SetAttribute("CanMinimize", $windowPattern.Current.CanMinimize) - $newXmlElement.SetAttribute("IsModal", $windowPattern.Current.IsModal) - $newXmlElement.SetAttribute("WindowVisualState", $windowPattern.Current.WindowVisualState) - $newXmlElement.SetAttribute("WindowInteractionState", $windowPattern.Current.WindowInteractionState) - $newXmlElement.SetAttribute("IsTopmost", $windowPattern.Current.IsTopmost) + $newXmlElement.SetAttribute("CanMaximize", $pattern.Current.CanMaximize) + $newXmlElement.SetAttribute("CanMinimize", $pattern.Current.CanMinimize) + $newXmlElement.SetAttribute("IsModal", $pattern.Current.IsModal) + $newXmlElement.SetAttribute("WindowVisualState", $pattern.Current.WindowVisualState) + $newXmlElement.SetAttribute("WindowInteractionState", $pattern.Current.WindowInteractionState) + $newXmlElement.SetAttribute("IsTopmost", $pattern.Current.IsTopmost) } if ($element.TryGetCurrentPattern([TransformPattern]::Pattern, [ref]$pattern)) { - $newXmlElement.SetAttribute("CanRotate", $windowPattern.Current.CanRotate) - $newXmlElement.SetAttribute("CanResize", $windowPattern.Current.CanResize) - $newXmlElement.SetAttribute("CanMove", $windowPattern.Current.CanMove) + $newXmlElement.SetAttribute("CanRotate", $pattern.Current.CanRotate) + $newXmlElement.SetAttribute("CanResize", $pattern.Current.CanResize) + $newXmlElement.SetAttribute("CanMove", $pattern.Current.CanMove) } # TODO: more to be added depending on the available patterns @@ -189,4 +189,4 @@ export const PAGE_SOURCE = pwsh /* ps1 */ ` return $xmlElement } -`; \ No newline at end of file +`; diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 9a55d43..752f946 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -5,6 +5,7 @@ import * as extension from './extension'; import * as device from './device'; import * as system from './system'; import * as app from './app'; +import * as contexts from './contexts'; const commands = { ...actions, @@ -14,6 +15,7 @@ const commands = { ...system, ...device, ...app, + ...contexts, // add the rest of the commands here }; diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index ed70957..4166a5a 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import net from 'node:net'; import { NovaWindowsDriver } from '../driver'; import { errors } from '@appium/base-driver'; import { FIND_CHILDREN_RECURSIVELY, PAGE_SOURCE } from './functions'; @@ -11,10 +12,13 @@ const INIT_ROOT_ELEMENT = /* ps1 */ `$rootElement = [AutomationElement]::RootEle const NULL_ROOT_ELEMENT = /* ps1 */ `$rootElement = $null`; const INIT_ELEMENT_TABLE = /* ps1 */ `$elementTable = New-Object System.Collections.Generic.Dictionary[[string]\`,[AutomationElement]]`; +const DEFAULT_WEBVIEW_DEVTOOLS_PORT_LOWER = 10900; +const DEFAULT_WEBVIEW_DEVTOOLS_PORT_UPPER = 11000; + export async function startPowerShellSession(this: NovaWindowsDriver): Promise { const powerShell = spawn('powershell.exe', ['-NoExit', '-Command', '-']); powerShell.stdout.setEncoding('utf8'); - powerShell.stdout.setEncoding('utf8'); + powerShell.stderr.setEncoding('utf8'); powerShell.stdout.on('data', (chunk: any) => { this.powerShellStdOut += chunk.toString(); @@ -76,6 +80,15 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise { - this.powerShellStdOut += chunk.toString(); + localStdOut += chunk.toString(); }); powerShell.stderr.on('data', (chunk: any) => { - this.powerShellStdErr += chunk.toString(); + localStdErr += 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 = ''; + localStdOut = ''; + localStdErr = ''; powerShell.stdin.write(`${SET_UTF8_ENCODING}\n`); if (this.caps.appWorkingDir) { @@ -130,17 +143,17 @@ export async function sendIsolatedPowerShellCommand(this: NovaWindowsDriver, com powerShell.stdin.write(`${command}\n`); powerShell.stdin.write(/* ps1 */ `Write-Output $([char]0x${magicNumber.toString(16)})\n`); - const onData: Parameters[1] = ((chunk: any) => { + 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)); + if (localStdErr) { + reject(new errors.UnknownError(localStdErr)); } else { - resolve(this.powerShellStdOut.replace(`${magicChar}`, '').trim()); + resolve(localStdOut.replace(`${magicChar}`, '').trim()); } } - }).bind(this); + }; powerShell.stdout.on('data', onData); }); @@ -228,3 +241,20 @@ export async function terminatePowerShellSession(this: NovaWindowsDriver): Promi await waitForClose; this.log.debug(`PowerShell session terminated successfully.`); } + +async function findFreePort(start: number, end: number): Promise { + for (let port = start; port <= end; port++) { + const isFree = await new Promise((resolve) => { + const server = net.createServer() + .once('error', () => resolve(false)) // port in use + .once('listening', () => server.close(() => resolve(true))) // port free + .listen(port); + }); + + if (isFree) { + return port; + } + } + + throw new errors.InvalidArgumentError(`No free port found between ${start} and ${end}. Consider specifying a port explicitly via the 'webviewDevtoolsPort' capability.`); +} diff --git a/lib/commands/screen-recorder.ts b/lib/commands/screen-recorder.ts new file mode 100644 index 0000000..db666c0 --- /dev/null +++ b/lib/commands/screen-recorder.ts @@ -0,0 +1,229 @@ +import type { AppiumLogger } from '@appium/types'; +import { fs, net, system, util } from 'appium/support'; +import { waitForCondition } from 'asyncbox'; +import { SubProcess } from 'teen_process'; +import { getBundledFfmpegPath } from '../util'; + +const RETRY_PAUSE = 300; +const RETRY_TIMEOUT = 5000; +const DEFAULT_TIME_LIMIT = 60 * 10; // 10 minutes +const PROCESS_SHUTDOWN_TIMEOUT = 10 * 1000; +export const DEFAULT_EXT = 'mp4'; +const DEFAULT_FPS = 15; +const DEFAULT_PRESET = 'veryfast'; + +export interface ScreenRecorderOptions { + fps?: number; + timeLimit?: number; + preset?: string; + captureCursor?: boolean; + captureClicks?: boolean; + audioInput?: string; + videoFilter?: string; +} + +export interface UploadOptions { + remotePath?: string; + user?: string; + pass?: string; + method?: string; + headers?: Record; + fileFieldName?: string; + formFields?: Array<[string, string]> | Record; +} + +async function requireFfmpegPath(): Promise { + const bundled = getBundledFfmpegPath(); + if (bundled) { + return bundled; + } + const ffmpegBinary = `ffmpeg${system.isWindows() ? '.exe' : ''}`; + try { + return await fs.which(ffmpegBinary); + } catch { + throw new Error( + `${ffmpegBinary} has not been found in PATH and the bundled ffmpeg is missing. ` + + 'Please reinstall the driver or install ffmpeg manually.', + ); + } +} + +export async function uploadRecordedMedia( + localFile: string, + remotePath?: string, + uploadOptions: Omit = {}, +): Promise { + if (!remotePath) { + return (await util.toInMemoryBase64(localFile)).toString(); + } + const { user, pass, method, headers, fileFieldName, formFields } = uploadOptions; + const options: Record = { + method: method ?? 'PUT', + headers, + fileFieldName, + formFields, + }; + if (user && pass) { + options.auth = { user, pass }; + } + await net.uploadFile(localFile, remotePath, options as Parameters[2]); + return ''; +} + +export class ScreenRecorder { + private log: AppiumLogger; + private _videoPath: string; + private _process: SubProcess | null = null; + private _fps: number; + private _audioInput?: string; + private _captureCursor: boolean; + private _captureClicks: boolean; + private _preset: string; + private _videoFilter?: string; + private _timeLimit: number; + + constructor(videoPath: string, log: AppiumLogger, opts: ScreenRecorderOptions = {}) { + this.log = log; + this._videoPath = videoPath; + this._fps = opts.fps && opts.fps > 0 ? opts.fps : DEFAULT_FPS; + this._audioInput = opts.audioInput; + this._captureCursor = opts.captureCursor ?? false; + this._captureClicks = opts.captureClicks ?? false; + this._preset = opts.preset ?? DEFAULT_PRESET; + this._videoFilter = opts.videoFilter; + this._timeLimit = opts.timeLimit && opts.timeLimit > 0 ? opts.timeLimit : DEFAULT_TIME_LIMIT; + } + + async getVideoPath(): Promise { + if (!(await fs.exists(this._videoPath))) { + return ''; + } + const stat = await fs.stat(this._videoPath); + if (!stat.isFile()) { + throw new Error( + `The video path '${this._videoPath}' does not point to a regular file and will not be deleted`, + ); + } + return this._videoPath; + } + + isRunning(): boolean { + return !!this._process?.isRunning; + } + + async _enforceTermination(): Promise { + if (this._process && this.isRunning()) { + this.log.debug('Force-stopping the currently running video recording'); + try { + await this._process.stop('SIGKILL'); + } catch {} + } + this._process = null; + const videoPath = await this.getVideoPath(); + if (videoPath) { + await fs.rimraf(videoPath); + } + return ''; + } + + async start(): Promise { + const ffmpegPath = await requireFfmpegPath(); + + const args: string[] = [ + '-loglevel', 'error', + '-t', String(this._timeLimit), + '-f', 'gdigrab', + ...(this._captureCursor ? ['-capture_cursor', '1'] : []), + ...(this._captureClicks ? ['-capture_mouse_clicks', '1'] : []), + '-framerate', String(this._fps), + '-i', 'desktop', + ...(this._audioInput ? ['-f', 'dshow', '-i', `audio=${this._audioInput}`] : []), + '-vcodec', 'libx264', + '-preset', this._preset, + '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + '-fflags', 'nobuffer', + '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + ...(this._videoFilter ? ['-filter:v', this._videoFilter] : []), + this._videoPath, + ]; + + this._process = new SubProcess(ffmpegPath, args, { windowsHide: true }); + this.log.debug(`Starting ffmpeg: ${util.quote([ffmpegPath, ...args])}`); + + this._process.on('output', (stdout: string, stderr: string) => { + const out = stdout || stderr; + if (out?.trim()) { + this.log.debug(`[ffmpeg] ${out}`); + } + }); + + this._process.once('exit', async (code: number, signal: string) => { + this._process = null; + if (code === 0) { + this.log.debug('Screen recording exited without errors'); + } else { + await this._enforceTermination(); + this.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); + } + }); + + await this._process.start(0); + + try { + await waitForCondition( + async () => { + if (await this.getVideoPath()) { + return true; + } + if (!this._process) { + throw new Error('ffmpeg process died unexpectedly'); + } + return false; + }, + { waitMs: RETRY_TIMEOUT, intervalMs: RETRY_PAUSE }, + ); + } catch { + await this._enforceTermination(); + throw new Error( + `The expected screen record file '${this._videoPath}' does not exist. ` + + 'Check the server log for more details', + ); + } + + this.log.info( + `The video recording has started. Will timeout in ${util.pluralize('second', this._timeLimit, true)}`, + ); + } + + async stop(force = false): Promise { + if (force) { + return await this._enforceTermination(); + } + + if (!this.isRunning()) { + this.log.debug('Screen recording is not running. Returning the recent result'); + return await this.getVideoPath(); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(async () => { + await this._enforceTermination(); + reject(new Error(`Screen recording has failed to exit after ${PROCESS_SHUTDOWN_TIMEOUT}ms`)); + }, PROCESS_SHUTDOWN_TIMEOUT); + + this._process?.once('exit', async (code: number, signal: string) => { + clearTimeout(timer); + if (code === 0) { + resolve(await this.getVideoPath()); + } else { + reject(new Error(`Screen recording exited with error code ${code}, signal ${signal}`)); + } + }); + + this._process?.proc?.stdin?.write('q'); + this._process?.proc?.stdin?.end(); + }); + } +} diff --git a/lib/constants.ts b/lib/constants.ts index 54e405e..7fb61da 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1 +1,2 @@ -export const POWER_SHELL_FEATURE = 'power_shell'; \ No newline at end of file +export const POWER_SHELL_FEATURE = 'power_shell'; +export const MODIFY_FS_FEATURE = 'modify_fs'; \ No newline at end of file diff --git a/lib/constraints.ts b/lib/constraints.ts index ce24b0a..7e2108b 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -35,7 +35,19 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { }, isolatedScriptExecution: { isBoolean: true, - } + }, + 'ms:waitForAppLaunch': { + isNumber: true, + }, + 'ms:forcequit': { + isBoolean: true, + }, + 'enableWebView': { + isBoolean: true, + }, + 'webviewDevtoolsPort': { + isNumber: true, + }, } as const satisfies Constraints; export default UI_AUTOMATION_DRIVER_CONSTRAINTS; diff --git a/lib/driver.ts b/lib/driver.ts index 8e7a551..9042fd5 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -1,34 +1,36 @@ -import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { BaseDriver, W3C_ELEMENT_KEY, errors } from '@appium/base-driver'; import { system } from 'appium/support'; +import { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { ScreenRecorder } from './commands/screen-recorder'; import commands from './commands'; import { - UI_AUTOMATION_DRIVER_CONSTRAINTS, - NovaWindowsDriverConstraints + NovaWindowsDriverConstraints, + UI_AUTOMATION_DRIVER_CONSTRAINTS } from './constraints'; import { - assertSupportedEasingFunction -} from './util'; -import { - Condition, - PropertyCondition, AutomationElement, + Condition, FoundAutomationElement, - TreeScope, - Property, - convertStringToCondition, - PSString, PSControlType, PSInt32Array, + PSString, + Property, + PropertyCondition, + TreeScope, + convertStringToCondition, } from './powershell'; -import { xpathToElIdOrIds } from './xpath'; +import { assertSupportedEasingFunction } from './util'; import { setDpiAwareness } from './winapi/user32'; +import { xpathToElIdOrIds } from './xpath'; +import type { Chromedriver } from 'appium-chromedriver'; import type { DefaultCreateSessionResult, DriverData, Element, + ExternalDriver, InitialOpts, + RouteMatcher, StringRecord, W3CDriverCaps } from '@appium/types'; @@ -54,6 +56,30 @@ const LOCATION_STRATEGIES = Object.freeze([ '-windows uiautomation', ] as const); +// This is a set of methods and paths that we never want to proxy to Chromedriver. +const CHROMEDRIVER_NO_PROXY: RouteMatcher[] = [ + ['GET', new RegExp('^/session/[^/]+/appium')], + ['GET', new RegExp('^/session/[^/]+/context')], + ['GET', new RegExp('^/session/[^/]+/element/[^/]+/rect')], + ['GET', new RegExp('^/session/[^/]+/orientation')], + ['POST', new RegExp('^/session/[^/]+/appium')], + ['POST', new RegExp('^/session/[^/]+/context')], + ['POST', new RegExp('^/session/[^/]+/orientation')], + + // this is needed to make the windows: and powerShell commands work in web context + ['POST', new RegExp('^/session/[^/]+/execute$')], + ['POST', new RegExp('^/session/[^/]+/execute/sync')], + + // MJSONWP commands + ['GET', new RegExp('^/session/[^/]+/log/types$')], + ['POST', new RegExp('^/session/[^/]+/log$')], + // W3C commands + // For Selenium v4 (W3C does not have this route) + ['GET', new RegExp('^/session/[^/]+/se/log/types$')], + // For Selenium v4 (W3C does not have this route) + ['POST', new RegExp('^/session/[^/]+/se/log$')], +]; + export class NovaWindowsDriver extends BaseDriver { isPowerShellSessionStarted: boolean = false; powerShell?: ChildProcessWithoutNullStreams; @@ -66,6 +92,14 @@ export class NovaWindowsDriver extends BaseDriver any) | null = null; + proxyCommand: ExternalDriver['proxyCommand'] | null = null; + contexts: string[] = []; + jwpProxyActive: boolean = false; + currentContext: string | null = null; + _screenRecorder: ScreenRecorder | null = null; + webviewDevtoolsPort: number | null = null; constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) { super(opts, shouldValidateCaps); @@ -73,11 +107,25 @@ export class NovaWindowsDriver extends BaseDriver { [strategy, selector] = this.processSelector(strategy, selector); return super.findElement(strategy, selector); @@ -197,22 +245,30 @@ export class NovaWindowsDriver extends BaseDriver id.trim()).filter(Boolean)[0]; - if (elementId) { - await this.sendPowerShellCommand(new FoundAutomationElement(elementId).buildCloseCommand()); + if (this.caps['ms:forcequit'] === true) { + await this.sendPowerShellCommand(/* ps1 */ ` + if ($null -ne $rootElement) { + $processId = $rootElement.Current.ProcessId + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + `); + } else { + const result = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand()); + const elementId = result.split('\n').map((id) => id.trim()).filter(Boolean)[0]; + if (elementId) { + await this.sendPowerShellCommand(new FoundAutomationElement(elementId).buildCloseCommand()); + } } } catch { // noop } - } // change to close the whole process, not only the window - await this.terminatePowerShellSession(); - + } if (this.caps.postrun) { this.log.info('Executing postrun PowerShell script...'); await this.executePowerShellScript(this.caps.postrun as Exclude[0], string>); } + await this.terminatePowerShellSession(); await super.deleteSession(sessionId); } diff --git a/lib/powershell/common.ts b/lib/powershell/common.ts index 6dc91f5..295462b 100644 --- a/lib/powershell/common.ts +++ b/lib/powershell/common.ts @@ -95,7 +95,7 @@ export class PSControlType extends PSObject { export class PSPoint extends PSObject { constructor(value: Position) { const requiredFields = ['x', 'y']; - if (!(Object.keys(value).every(requiredFields.includes) && typeof value.x === 'number' && typeof value.y === 'number')) { + if (!(requiredFields.every((f) => f in value) && typeof value.x === 'number' && typeof value.y === 'number')) { throw new errors.InvalidArgumentError('PSPoint accepts a Position object { x: number, y: number } in the constructor.'); } @@ -106,7 +106,7 @@ export class PSPoint extends PSObject { export class PSRect extends PSObject { constructor(value: Rect) { const requiredFields = ['x', 'y', 'width', 'height']; - if (!(Object.keys(value).every(requiredFields.includes) && typeof value.x === 'number' && typeof value.y === 'number' && typeof value.width === 'number' && typeof value.height === 'number')) { + if (!(requiredFields.every((f) => f in value) && typeof value.x === 'number' && typeof value.y === 'number' && typeof value.width === 'number' && typeof value.height === 'number')) { throw new errors.InvalidArgumentError('PSRect accepts a Rect object { x: number, y: number, width: number, height: number } in the constructor.'); } @@ -128,7 +128,7 @@ export class PSCultureInfo extends PSObject { constructor(name: string, useUserOverride?: boolean) constructor(culture: number, useUserOverride?: boolean) constructor(nameOrCulture: string | number, useUserOverride?: boolean) { - if (typeof nameOrCulture !== 'string' || (typeof nameOrCulture === 'number' && nameOrCulture < 0)) { + if (typeof nameOrCulture !== 'string' && (typeof nameOrCulture !== 'number' || nameOrCulture < 0)) { throw new errors.InvalidArgumentError('PSCultureInfo accepts a string or positive integer value in the constructor.'); } diff --git a/lib/powershell/elements.ts b/lib/powershell/elements.ts index 767951f..816ba9f 100644 --- a/lib/powershell/elements.ts +++ b/lib/powershell/elements.ts @@ -348,6 +348,8 @@ const MAXIMIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]: const MINIMIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).SetWindowVisualState([WindowVisualState]::Minimized)`; const RESTORE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).SetWindowVisualState([WindowVisualState]::Normal)`; const CLOSE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([WindowPattern]::Pattern).Close()`; +const MOVE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([TransformPattern]::Pattern).Move(${1}, ${2})`; +const RESIZE_WINDOW = pwsh$ /* ps1 */ `${0}.GetCurrentPattern([TransformPattern]::Pattern).Resize(${1}, ${2})`; export const TreeScope = Object.freeze({ ANCESTORS_OR_SELF: 'ancestors-or-self', @@ -578,6 +580,14 @@ export class FoundAutomationElement extends AutomationElement { return CLOSE_WINDOW.format(this); } + buildMoveCommand(x: number, y: number): string { + return MOVE_WINDOW.format(this, x, y); + } + + buildResizeCommand(width: number, height: number): string { + return RESIZE_WINDOW.format(this, width, height); + } + override buildCommand(): string { return this.toString(); } diff --git a/lib/util.ts b/lib/util.ts index 0453e98..02f8f7c 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,5 +1,19 @@ import { errors } from '@appium/base-driver'; +/** + * Resolves the path to the bundled ffmpeg binary from the ffmpeg-static package. + * Used by startRecordingScreen; no system PATH fallback. + */ +export function getBundledFfmpegPath(): string | null { + try { + const mod = require('ffmpeg-static') as string | { default?: string } | undefined; + const path = typeof mod === 'string' ? mod : mod?.default; + return typeof path === 'string' && path.length > 0 ? path : null; + } catch { + return null; + } +} + const SupportedEasingFunctions = Object.freeze([ 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', ]); diff --git a/lib/winapi/user32.ts b/lib/winapi/user32.ts index 6abe67c..5a7f046 100644 --- a/lib/winapi/user32.ts +++ b/lib/winapi/user32.ts @@ -449,6 +449,7 @@ function makeMouseMoveEvents(args: { const verticalScrollEvent = makeEmptyMouseEvent(); verticalScrollEvent.u.mi.dwFlags = MouseEventFlags.MOUSEEVENTF_WHEEL; verticalScrollEvent.u.mi.mouseData = y; + mouseEvents.push(verticalScrollEvent); } return mouseEvents; diff --git a/package.json b/package.json index dcd5845..8562eb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.1.0", + "version": "1.3.1", "description": "Appium driver for Windows", "keywords": [ "appium", @@ -15,7 +15,8 @@ "build": "tsc -b", "watch": "tsc -b --watch", "lint": "eslint .", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "npx vitest run", + "test:e2e": "npx vitest run --config vitest.e2e.config.ts" }, "author": "Automate The Planet", "license": "Apache-2.0", @@ -27,11 +28,13 @@ "url": "https://github.com/AutomateThePlanet/appium-novawindows-driver/issues" }, "peerDependencies": { - "appium": "^3.1.0" + "appium": "^3.0.0-rc.2" }, "dependencies": { "@appium/base-driver": "^10.1.0", + "appium-chromedriver": "^8.2.21", "bezier-easing": "^2.1.0", + "ffmpeg-static": "^5.2.0", "koffi": "^2.14.1", "xpath-analyzer": "^3.0.1" }, @@ -55,6 +58,8 @@ "eslint": "^9.38.0", "semantic-release": "^25.0.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.1" + "typescript-eslint": "^8.46.1", + "vitest": "^2.1.0", + "webdriverio": "^9.0.0" } } diff --git a/test/commands/app/app.test.ts b/test/commands/app/app.test.ts new file mode 100644 index 0000000..4e44a49 --- /dev/null +++ b/test/commands/app/app.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for additional lib/commands/app.ts functions + * (getPageSource, getWindowHandle, getWindowHandles, getWindowRect, setWindow) + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getPageSource, + getWindowHandle, + getWindowHandles, + getWindowRect, + setWindow, +} from '../../../lib/commands/app'; +import { createMockDriver } from '../../fixtures/driver'; + +vi.mock('../../../lib/winapi/user32', () => ({ + getWindowAllHandlesForProcessIds: vi.fn().mockReturnValue([]), + trySetForegroundWindow: vi.fn().mockReturnValue(true), +})); + +describe('getPageSource', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns XML page source string', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + const result = await getPageSource.call(driver); + expect(result).toBe(''); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + }); +}); + +describe('getWindowHandle', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns hex-formatted window handle', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('12648430'); // 0x00C0FFEE + const result = await getWindowHandle.call(driver); + expect(result).toBe('0x00c0ffee'); + }); + + it('pads handle to 8 hex digits', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('1'); // 0x00000001 + const result = await getWindowHandle.call(driver); + expect(result).toBe('0x00000001'); + }); +}); + +describe('getWindowHandles', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns array of hex window handles for each child window', async () => { + const driver = createMockDriver() as any; + // First call: findAll children of rootElement → two element IDs + // Subsequent calls: getNativeWindowHandle for each child + driver.sendPowerShellCommand + .mockResolvedValueOnce('1.1.1\n2.2.2') // findAll + .mockResolvedValueOnce('100') // handle for element 1 + .mockResolvedValueOnce('200'); // handle for element 2 + + const result = await getWindowHandles.call(driver); + expect(result).toHaveLength(2); + expect(result[0]).toBe('0x00000064'); // 100 = 0x64 + expect(result[1]).toBe('0x000000c8'); // 200 = 0xC8 + }); + + it('returns empty array when no child windows found', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); // no elements + const result = await getWindowHandles.call(driver); + expect(result).toEqual([]); + }); +}); + +describe('getWindowRect', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns parsed rect object', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('{"x":10,"y":20,"width":800,"height":600}'); + const result = await getWindowRect.call(driver); + expect(result).toEqual({ x: 10, y: 20, width: 800, height: 600 }); + }); + + it('replaces Infinity with max int32', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('{"x":Infinity,"y":0,"width":Infinity,"height":0}'); + const result = await getWindowRect.call(driver); + expect(result.x).toBe(0x7FFFFFFF); + expect(result.width).toBe(0x7FFFFFFF); + }); +}); + +describe('setWindow', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sets root element by numeric handle', async () => { + const driver = createMockDriver() as any; + const { trySetForegroundWindow } = await import('../../../lib/winapi/user32'); + + // findFirst returns a valid element ID + driver.sendPowerShellCommand.mockResolvedValue('1.2.3'); + await setWindow.call(driver, '12345'); + expect(driver.sendPowerShellCommand).toHaveBeenCalled(); + // The last PS command should set $rootElement + const calls = driver.sendPowerShellCommand.mock.calls; + const setRootCall = calls.find((c: any[]) => c[0].includes('$rootElement =')); + expect(setRootCall).toBeDefined(); + expect(trySetForegroundWindow).toHaveBeenCalledWith(12345); + }); + + it('sets root element by window name', async () => { + const driver = createMockDriver() as any; + + driver.sendPowerShellCommand + .mockResolvedValueOnce('') // numeric handle search fails (NaN) + .mockResolvedValueOnce('5.6.7'); // name search succeeds + + await setWindow.call(driver, 'Calculator'); + const calls = driver.sendPowerShellCommand.mock.calls; + const setRootCall = calls.find((c: any[]) => c[0].includes('$rootElement =')); + expect(setRootCall).toBeDefined(); + }); + + it('throws NoSuchWindowError when window is not found after retries', async () => { + const driver = createMockDriver() as any; + // All calls return empty (window not found) + driver.sendPowerShellCommand.mockResolvedValue(''); + + await expect(setWindow.call(driver, 'NonExistentWindow')).rejects.toThrow('No window was found'); + }, 10000); +}); diff --git a/test/commands/app/closeApp.test.ts b/test/commands/app/closeApp.test.ts new file mode 100644 index 0000000..359a91e --- /dev/null +++ b/test/commands/app/closeApp.test.ts @@ -0,0 +1,38 @@ +/** + * Unit tests for the W3C closeApp command (session-scoped). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeApp } from '../../../lib/commands/app'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('closeApp (W3C)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('closes the session app window via UI Automation close', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce('element-123') // automationRoot.buildCommand() + .mockResolvedValueOnce(undefined) // buildCloseCommand() + .mockResolvedValueOnce(undefined); // $rootElement = $null + + await closeApp.call(driver); + + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(3); + }); + + it('throws NoSuchWindowError when no root element exists', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValueOnce(''); // empty = window already gone + + await expect(closeApp.call(driver)).rejects.toThrow('No active app window'); + }); + + it('throws NoSuchWindowError when root element returns only whitespace', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValueOnce(' \n \n '); + + await expect(closeApp.call(driver)).rejects.toThrow('No active app window'); + }); +}); diff --git a/test/commands/app/launchApp.test.ts b/test/commands/app/launchApp.test.ts new file mode 100644 index 0000000..6531608 --- /dev/null +++ b/test/commands/app/launchApp.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for the W3C launchApp command (session-scoped). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { launchApp } from '../../../lib/commands/app'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('launchApp (W3C)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('re-launches the session app via changeRootElement', async () => { + const driver = createMockDriver() as any; + driver.caps = { app: 'C:\\Program Files\\notepad.exe' }; + driver.changeRootElement = vi.fn().mockResolvedValue(undefined); + + await launchApp.call(driver); + + expect(driver.changeRootElement).toHaveBeenCalledWith('C:\\Program Files\\notepad.exe'); + expect(driver.changeRootElement).toHaveBeenCalledTimes(1); + }); + + it('re-launches a UWP app via changeRootElement', async () => { + const driver = createMockDriver() as any; + driver.caps = { app: 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App' }; + driver.changeRootElement = vi.fn().mockResolvedValue(undefined); + + await launchApp.call(driver); + + expect(driver.changeRootElement).toHaveBeenCalledWith('Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'); + }); + + it('throws InvalidArgumentError when app capability is not set', async () => { + const driver = createMockDriver() as any; + driver.caps = {}; + + await expect(launchApp.call(driver)).rejects.toThrow('No app capability is set'); + }); + + it('throws InvalidArgumentError when app is "root"', async () => { + const driver = createMockDriver() as any; + driver.caps = { app: 'root' }; + + await expect(launchApp.call(driver)).rejects.toThrow('No app capability is set'); + }); + + it('throws InvalidArgumentError when app is "none"', async () => { + const driver = createMockDriver() as any; + driver.caps = { app: 'none' }; + + await expect(launchApp.call(driver)).rejects.toThrow('No app capability is set'); + }); +}); diff --git a/test/commands/app/navigation.test.ts b/test/commands/app/navigation.test.ts new file mode 100644 index 0000000..86c51b8 --- /dev/null +++ b/test/commands/app/navigation.test.ts @@ -0,0 +1,213 @@ +/** + * Unit tests for lib/commands/app.ts: back, forward, getTitle, setWindowRect + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { back, forward, title, setWindowRect } from '../../../lib/commands/app'; +import { createMockDriver } from '../../fixtures/driver'; +import { Key } from '../../../lib/enums'; + +vi.mock('../../../lib/winapi/user32', () => ({ + getWindowAllHandlesForProcessIds: vi.fn().mockReturnValue([]), + trySetForegroundWindow: vi.fn().mockReturnValue(true), + keyDown: vi.fn(), + keyUp: vi.fn(), +})); + +const ELEMENT_ID = '1.2.3.4.5'; + +describe('back', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends Alt+Left when a window is active', async () => { + const { keyDown, keyUp } = await import('../../../lib/winapi/user32'); + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID); + + await back.call(driver); + + expect(keyDown).toHaveBeenNthCalledWith(1, Key.ALT); + expect(keyDown).toHaveBeenNthCalledWith(2, Key.LEFT); + expect(keyUp).toHaveBeenNthCalledWith(1, Key.LEFT); + expect(keyUp).toHaveBeenNthCalledWith(2, Key.ALT); + }); + + it('throws NoSuchWindowError when no active window', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + + await expect(back.call(driver)).rejects.toThrow('No active window found'); + }); + + it('performs exactly one PS call (window check) before sending keys', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID); + + await back.call(driver); + + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + }); +}); + +describe('forward', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends Alt+Right when a window is active', async () => { + const { keyDown, keyUp } = await import('../../../lib/winapi/user32'); + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID); + + await forward.call(driver); + + expect(keyDown).toHaveBeenNthCalledWith(1, Key.ALT); + expect(keyDown).toHaveBeenNthCalledWith(2, Key.RIGHT); + expect(keyUp).toHaveBeenNthCalledWith(1, Key.RIGHT); + expect(keyUp).toHaveBeenNthCalledWith(2, Key.ALT); + }); + + it('throws NoSuchWindowError when no active window', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + + await expect(forward.call(driver)).rejects.toThrow('No active window found'); + }); +}); + +describe('title (getTitle)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns the window title from the Name property', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce(ELEMENT_ID) // window check + .mockResolvedValueOnce('Untitled - Notepad'); // Name property + + const result = await title.call(driver); + + expect(result).toBe('Untitled - Notepad'); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2); + }); + + it('returns an empty string when the window has no title', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce(ELEMENT_ID) + .mockResolvedValueOnce(''); + + const result = await title.call(driver); + + expect(result).toBe(''); + }); + + it('throws NoSuchWindowError when no active window', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + + await expect(title.call(driver)).rejects.toThrow('No active window found'); + }); +}); + +describe('setWindowRect', () => { + beforeEach(() => vi.clearAllMocks()); + + const MOCK_RECT = { x: 100, y: 100, width: 800, height: 600 }; + + function createDriverWithRect(windowRect = MOCK_RECT) { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(ELEMENT_ID); + driver.getWindowRect = vi.fn().mockResolvedValue(windowRect); + return driver; + } + + it('calls Move and Resize when all four values are provided', async () => { + const driver = createDriverWithRect(); + + const result = await setWindowRect.call(driver, 100, 100, 800, 600); + + // PS calls: window check + Move + Resize = 3 + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(3); + expect(driver.getWindowRect).toHaveBeenCalledTimes(1); + expect(result).toEqual(MOCK_RECT); + }); + + it('calls only Move when width and height are null', async () => { + const driver = createDriverWithRect(); + + await setWindowRect.call(driver, 50, 75, null, null); + + // PS calls: window check + Move = 2 + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2); + }); + + it('calls only Resize when x and y are null', async () => { + const driver = createDriverWithRect(); + + await setWindowRect.call(driver, null, null, 1024, 768); + + // PS calls: window check + Resize = 2 + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2); + }); + + it('skips Move and Resize when all arguments are null', async () => { + const driver = createDriverWithRect(); + + await setWindowRect.call(driver, null, null, null, null); + + // PS calls: window check only = 1 + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + }); + + it('returns the new window rect from getWindowRect', async () => { + const expectedRect = { x: 200, y: 300, width: 1024, height: 768 }; + const driver = createDriverWithRect(expectedRect); + + const result = await setWindowRect.call(driver, 200, 300, 1024, 768); + + expect(result).toEqual(expectedRect); + }); + + it('throws InvalidArgumentError for negative width', async () => { + const driver = createDriverWithRect(); + + await expect(setWindowRect.call(driver, 0, 0, -1, 600)).rejects.toThrow('width must be a non-negative integer'); + }); + + it('throws InvalidArgumentError for negative height', async () => { + const driver = createDriverWithRect(); + + await expect(setWindowRect.call(driver, 0, 0, 800, -1)).rejects.toThrow('height must be a non-negative integer'); + }); + + it('throws NoSuchWindowError when no active window', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + driver.getWindowRect = vi.fn(); + + await expect(setWindowRect.call(driver, 0, 0, 800, 600)).rejects.toThrow('No active window found'); + }); + + it('the Move PS command contains TransformPattern and Move', async () => { + const driver = createDriverWithRect(); + + await setWindowRect.call(driver, 10, 20, null, null); + + const moveCmdCall = driver.sendPowerShellCommand.mock.calls[1][0] as string; + const decoded = moveCmdCall.replace(/FromBase64String\('([^']+)'\)/g, (_, b64) => + Buffer.from(b64, 'base64').toString('utf8') + ); + expect(decoded).toContain('TransformPattern'); + expect(decoded).toContain('Move'); + }); + + it('the Resize PS command contains TransformPattern and Resize', async () => { + const driver = createDriverWithRect(); + + await setWindowRect.call(driver, null, null, 800, 600); + + const resizeCmdCall = driver.sendPowerShellCommand.mock.calls[1][0] as string; + const decoded = resizeCmdCall.replace(/FromBase64String\('([^']+)'\)/g, (_, b64) => + Buffer.from(b64, 'base64').toString('utf8') + ); + expect(decoded).toContain('TransformPattern'); + expect(decoded).toContain('Resize'); + }); +}); diff --git a/test/commands/device.test.ts b/test/commands/device.test.ts new file mode 100644 index 0000000..0ec804e --- /dev/null +++ b/test/commands/device.test.ts @@ -0,0 +1,44 @@ +/** + * Unit tests for lib/commands/device.ts + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getDeviceTime } from '../../lib/commands/device'; +import { createMockDriver } from '../fixtures/driver'; + +/** Decode base64 Invoke-Expression wrappers to reveal the underlying PS command. */ +function decodeCommand(cmd: string): string { + const match = cmd.match(/FromBase64String\('([^']+)'\)/); + if (!match) {return cmd;} + return decodeCommand(Buffer.from(match[1], 'base64').toString('utf8')); +} + +describe('getDeviceTime', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns formatted date string from PS command', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('2026-02-25T10:30:00+00:00'); + const result = await getDeviceTime.call(driver); + expect(result).toBe('2026-02-25T10:30:00+00:00'); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + }); + + it('uses ISO 8061 format by default when no format provided', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('2026-02-25T10:30:00+00:00'); + await getDeviceTime.call(driver); + const cmd = decodeCommand(driver.sendPowerShellCommand.mock.calls[0][0]); + expect(cmd).toContain('Get-Date'); + expect(cmd).toContain('yyyy-MM-ddTHH:mm:sszzz'); + }); + + it('uses custom format when provided as second argument', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('25/02/2026'); + const result = await getDeviceTime.call(driver, undefined, 'dd/MM/yyyy'); + expect(result).toBe('25/02/2026'); + const cmd = decodeCommand(driver.sendPowerShellCommand.mock.calls[0][0]); + expect(cmd).toContain('Get-Date'); + expect(cmd).toContain('ToString'); + }); +}); diff --git a/test/commands/element.test.ts b/test/commands/element.test.ts new file mode 100644 index 0000000..8ec1ec1 --- /dev/null +++ b/test/commands/element.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for lib/commands/element.ts + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getProperty, + getAttribute, + active, + getName, + getText, + clear, + getElementRect, + elementDisplayed, + elementSelected, + elementEnabled, + getElementScreenshot, +} from '../../lib/commands/element'; +import { createMockDriver } from '../fixtures/driver'; +import { W3C_ELEMENT_KEY } from '@appium/base-driver'; + +vi.mock('../../lib/winapi/user32', () => ({ + mouseDown: vi.fn(), + mouseUp: vi.fn(), + mouseMoveAbsolute: vi.fn().mockResolvedValue(undefined), +})); + +const ELEMENT_ID = '1.2.3.4.5'; + +describe('getProperty', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends GetCurrentPropertyValue command and returns result', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('Calculator'); + const result = await getProperty.call(driver, 'name', ELEMENT_ID); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + expect(result).toBe('Calculator'); + }); + + it('returns the value from sendPowerShellCommand for runtimeid property', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('1.2.3.4.5'); + const result = await getProperty.call(driver, 'runtimeid', ELEMENT_ID); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + expect(result).toBe('1.2.3.4.5'); + }); +}); + +describe('getAttribute', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates to getProperty and returns result', async () => { + const driver = createMockDriver() as any; + driver.getProperty = vi.fn().mockResolvedValue('SomeValue'); + driver.log.warn = vi.fn(); + const result = await getAttribute.call(driver, 'name', ELEMENT_ID); + expect(driver.getProperty).toHaveBeenCalledWith('name', ELEMENT_ID); + expect(result).toBe('SomeValue'); + }); +}); + +describe('active', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns the focused element wrapped in W3C element key', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('9.8.7.6.5'); + const result = await active.call(driver); + expect(result[W3C_ELEMENT_KEY]).toBe('9.8.7.6.5'); + }); +}); + +describe('getName', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns the tag name from the command', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('Button'); + const result = await getName.call(driver, ELEMENT_ID); + expect(result).toBe('Button'); + }); +}); + +describe('getText', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns the text content from the command', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('Hello World'); + const result = await getText.call(driver, ELEMENT_ID); + expect(result).toBe('Hello World'); + }); +}); + +describe('clear', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends a SetValue command with empty string', async () => { + const driver = createMockDriver() as any; + await clear.call(driver, ELEMENT_ID); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + // The command is base64-encoded; verify that 'SetValue' appears anywhere in the decoded chain + const rawCmd = driver.sendPowerShellCommand.mock.calls[0][0]; + // Decode all layers to find SetValue + const allDecoded = JSON.stringify(rawCmd) + .replace(/\\u[\dA-F]{4}/gi, '') + .replace(/FromBase64String\('([^']+)'\)/g, (_, b64) => Buffer.from(b64, 'base64').toString('utf8')); + expect(allDecoded).toContain('SetValue'); + }); +}); + +describe('getElementRect', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns rect adjusted relative to root rect', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce('{"x":110,"y":220,"width":50,"height":30}') + .mockResolvedValueOnce('{"x":100,"y":200,"width":800,"height":600}'); + + const result = await getElementRect.call(driver, ELEMENT_ID); + expect(result.x).toBe(10); // 110 - 100 + expect(result.y).toBe(20); // 220 - 200 + expect(result.width).toBe(50); + expect(result.height).toBe(30); + }); + + it('handles Infinity values by replacing with max int32', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce('{"x":Infinity,"y":0,"width":50,"height":30}') + .mockResolvedValueOnce('{"x":0,"y":0,"width":800,"height":600}'); + + const result = await getElementRect.call(driver, ELEMENT_ID); + expect(result.x).toBe(0x7FFFFFFF); + }); + + it('clamps adjusted x and y to max int32', async () => { + const driver = createMockDriver() as any; + // Element x is less than root x so result would be negative → clamped? Actually min(0x7FFFFFFF, value) + // Let's test when adjusted value exceeds max int32 + driver.sendPowerShellCommand + .mockResolvedValueOnce(`{"x":${0x7FFFFFFF},"y":0,"width":10,"height":10}`) + .mockResolvedValueOnce('{"x":0,"y":0,"width":800,"height":600}'); + + const result = await getElementRect.call(driver, ELEMENT_ID); + expect(result.x).toBe(0x7FFFFFFF); + }); +}); + +describe('elementDisplayed', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns true when IS_OFFSCREEN is false', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('False'); + const result = await elementDisplayed.call(driver, ELEMENT_ID); + expect(result).toBe(true); + }); + + it('returns false when IS_OFFSCREEN is true', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('True'); + const result = await elementDisplayed.call(driver, ELEMENT_ID); + expect(result).toBe(false); + }); +}); + +describe('elementSelected', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns true when SelectionItemPattern.IsSelected is True', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('True'); + const result = await elementSelected.call(driver, ELEMENT_ID); + expect(result).toBe(true); + }); + + it('returns false when SelectionItemPattern.IsSelected is not True', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('False'); + const result = await elementSelected.call(driver, ELEMENT_ID); + expect(result).toBe(false); + }); + + it('falls back to ToggleState when SelectionItemPattern throws', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockRejectedValueOnce(new Error('No SelectionItemPattern')) + .mockResolvedValueOnce('On'); + const result = await elementSelected.call(driver, ELEMENT_ID); + expect(result).toBe(true); + }); + + it('returns false from ToggleState when toggle is Off', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockRejectedValueOnce(new Error('No SelectionItemPattern')) + .mockResolvedValueOnce('Off'); + const result = await elementSelected.call(driver, ELEMENT_ID); + expect(result).toBe(false); + }); +}); + +describe('elementEnabled', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns true when IS_ENABLED is true', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('true'); + const result = await elementEnabled.call(driver, ELEMENT_ID); + expect(result).toBe(true); + }); + + it('returns false when IS_ENABLED is false', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('false'); + const result = await elementEnabled.call(driver, ELEMENT_ID); + expect(result).toBe(false); + }); +}); + +describe('getElementScreenshot', () => { + beforeEach(() => vi.clearAllMocks()); + + const ROOT_ID = '0.1.2.3'; + const FAKE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + it('returns base64 PNG from the screenshot command', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce(ROOT_ID) // window check + .mockResolvedValueOnce(FAKE_BASE64); // screenshot + + const result = await getElementScreenshot.call(driver, ELEMENT_ID); + + expect(result).toBe(FAKE_BASE64); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(2); + }); + + it('throws NoSuchWindowError when no active window', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + + await expect(getElementScreenshot.call(driver, ELEMENT_ID)).rejects.toThrow('No active window found'); + }); + + it('the screenshot PS command references the element and BoundingRectangle', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand + .mockResolvedValueOnce(ROOT_ID) + .mockResolvedValueOnce(FAKE_BASE64); + + await getElementScreenshot.call(driver, ELEMENT_ID); + + const screenshotCmd = driver.sendPowerShellCommand.mock.calls[1][0] as string; + const decoded = screenshotCmd.replace(/FromBase64String\('([^']+)'\)/g, (_, b64) => + Buffer.from(b64, 'base64').toString('utf8') + ); + expect(decoded).toContain('BoundingRectangle'); + expect(decoded).toContain('CopyFromScreen'); + }); +}); diff --git a/test/commands/extension/cacheRequest.test.ts b/test/commands/extension/cacheRequest.test.ts new file mode 100644 index 0000000..3bc0248 --- /dev/null +++ b/test/commands/extension/cacheRequest.test.ts @@ -0,0 +1,46 @@ +/** + * Unit tests for pushCacheRequest (cacheRequest) extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { pushCacheRequest } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('pushCacheRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when all properties are undefined', async () => { + const driver = createMockDriver() as any; + await expect( + pushCacheRequest.call(driver, {}) + ).rejects.toThrow('At least one property of the cache request must be set.'); + await expect( + pushCacheRequest.call(driver, { treeScope: undefined, treeFilter: undefined, automationElementMode: undefined }) + ).rejects.toThrow('At least one property of the cache request must be set.'); + expect(driver.sendPowerShellCommand).not.toHaveBeenCalled(); + }); + + it('sends treeFilter command when treeFilter is set', async () => { + const driver = createMockDriver() as any; + await pushCacheRequest.call(driver, { treeFilter: 'TrueCondition' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('TreeFilter') + ); + }); + + it('throws for invalid treeScope value', async () => { + const driver = createMockDriver() as any; + await expect( + pushCacheRequest.call(driver, { treeScope: 'InvalidScope' }) + ).rejects.toThrow('Invalid value'); + }); + + it('throws for invalid automationElementMode value', async () => { + const driver = createMockDriver() as any; + await expect( + pushCacheRequest.call(driver, { automationElementMode: 'InvalidMode' }) + ).rejects.toThrow('Invalid value'); + }); +}); diff --git a/test/commands/extension/clickAndDrag.test.ts b/test/commands/extension/clickAndDrag.test.ts new file mode 100644 index 0000000..abbd31d --- /dev/null +++ b/test/commands/extension/clickAndDrag.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for executeClickAndDrag extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { executeClickAndDrag } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +vi.mock('../../../lib/winapi/user32', () => ({ + keyDown: vi.fn(), + keyUp: vi.fn(), + mouseDown: vi.fn(), + mouseUp: vi.fn(), + mouseMoveAbsolute: vi.fn().mockResolvedValue(undefined), + mouseScroll: vi.fn(), + sendKeyboardEvents: vi.fn(), +})); + +describe('executeClickAndDrag', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when only startX is provided without startY', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + await expect( + executeClickAndDrag.call(driver, { startX: 100, endX: 200, endY: 200 }) + ).rejects.toThrow('Both startX and startY must be provided'); + }); + + it('throws when only endX is provided without endY', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + await expect( + executeClickAndDrag.call(driver, { startX: 0, startY: 0, endX: 100 }) + ).rejects.toThrow('Both endX and endY must be provided'); + }); + + it('throws when neither start coords nor startElementId provided', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + await expect( + executeClickAndDrag.call(driver, { endX: 100, endY: 100 }) + ).rejects.toThrow('Either startElementId or startX and startY must be provided'); + }); + + it('drags from start to end coordinates with mouseDown/mouseUp', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + const { mouseMoveAbsolute, mouseDown, mouseUp } = await import('../../../lib/winapi/user32'); + + await executeClickAndDrag.call(driver, { + startX: 0, startY: 0, + endX: 100, endY: 100, + }); + + expect(mouseMoveAbsolute).toHaveBeenCalledTimes(2); + expect(mouseMoveAbsolute).toHaveBeenNthCalledWith(1, 0, 0, 0); + expect(mouseMoveAbsolute).toHaveBeenNthCalledWith(2, 100, 100, 500, undefined); + expect(mouseDown).toHaveBeenCalledWith(0); + expect(mouseUp).toHaveBeenCalledWith(0); + }); + + it('drags with elementId when element exists', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + const rectJson = '{"x":10,"y":20,"width":100,"height":50}'; + const returnValues = ['True', '1.2.3.4.5', rectJson, 'True', '1.2.3.4.5', rectJson]; + let callIndex = 0; + driver.sendPowerShellCommand.mockImplementation(() => + Promise.resolve(returnValues[callIndex++] ?? rectJson) + ); + const { mouseMoveAbsolute } = await import('../../../lib/winapi/user32'); + + await executeClickAndDrag.call(driver, { + startElementId: '1.2.3.4.5', + endElementId: '1.2.3.4.5', + }); + + expect(mouseMoveAbsolute).toHaveBeenCalledTimes(2); + expect(mouseMoveAbsolute).toHaveBeenNthCalledWith(1, 60, 45, 0); + expect(mouseMoveAbsolute).toHaveBeenNthCalledWith(2, 60, 45, 500, undefined); + }); +}); diff --git a/test/commands/extension/clipboard.test.ts b/test/commands/extension/clipboard.test.ts new file mode 100644 index 0000000..9e89d68 --- /dev/null +++ b/test/commands/extension/clipboard.test.ts @@ -0,0 +1,117 @@ +/** + * Unit tests for getClipboardBase64 and setClipboardFromBase64 extension commands. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getClipboardBase64, setClipboardFromBase64 } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('getClipboardBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns plaintext clipboard by default', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('aGVsbG8='); + const result = await getClipboardBase64.call(driver); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Get-Clipboard') + ); + expect(result).toBe('aGVsbG8='); + }); + + it('accepts contentType as plaintext', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('dGVzdA=='); + const result = await getClipboardBase64.call(driver, 'plaintext'); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Get-Clipboard') + ); + expect(result).toBe('dGVzdA=='); + }); + + it('accepts contentType as image', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('iVBORw0KGgo='); + const result = await getClipboardBase64.call(driver, 'image'); + const callArg = driver.sendPowerShellCommand.mock.calls[0][0]; + const base64Match = callArg.match(/FromBase64String\('([^']+)'\)/); + const decoded = base64Match ? Buffer.from(base64Match[1], 'base64').toString() : ''; + expect(decoded).toContain('GetImage'); + expect(result).toBe('iVBORw0KGgo='); + }); + + it('accepts contentType as object with contentType property', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('YmFzZTY0'); + const result = await getClipboardBase64.call(driver, { contentType: 'plaintext' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Get-Clipboard') + ); + expect(result).toBe('YmFzZTY0'); + }); + + it('throws for unsupported content type', async () => { + const driver = createMockDriver() as any; + await expect( + getClipboardBase64.call(driver, 'unsupported' as any) + ).rejects.toThrow("Unsupported content type 'unsupported'"); + }); +}); + +describe('setClipboardFromBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when b64Content is missing', async () => { + const driver = createMockDriver() as any; + await expect( + setClipboardFromBase64.call(driver, {} as any) + ).rejects.toThrow("'b64Content' must be provided."); + await expect( + setClipboardFromBase64.call(driver, { contentType: 'plaintext' } as any) + ).rejects.toThrow("'b64Content' must be provided."); + }); + + it('sets plaintext clipboard by default', async () => { + const driver = createMockDriver() as any; + await setClipboardFromBase64.call(driver, { b64Content: 'aGVsbG8=' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('FromBase64String') + ); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Set-Clipboard') + ); + }); + + it('sets plaintext clipboard with explicit contentType', async () => { + const driver = createMockDriver() as any; + await setClipboardFromBase64.call(driver, { b64Content: 'dGVzdA==', contentType: 'plaintext' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('FromBase64String') + ); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('dGVzdA==') + ); + }); + + it('sets image clipboard', async () => { + const driver = createMockDriver() as any; + await setClipboardFromBase64.call(driver, { b64Content: 'iVBORw0KGgo=', contentType: 'image' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('FromBase64String') + ); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('SetImage') + ); + }); + + it('throws for unsupported content type', async () => { + const driver = createMockDriver() as any; + await expect( + setClipboardFromBase64.call(driver, { b64Content: 'abc', contentType: 'unsupported' as any }) + ).rejects.toThrow("Unsupported content type 'unsupported'"); + }); +}); diff --git a/test/commands/extension/deleteFile.test.ts b/test/commands/extension/deleteFile.test.ts new file mode 100644 index 0000000..6d8c3b0 --- /dev/null +++ b/test/commands/extension/deleteFile.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for deleteFile extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { deleteFile } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('deleteFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when path is not provided', async () => { + const driver = createMockDriver() as any; + await expect( + deleteFile.call(driver, {} as any) + ).rejects.toThrow("'path' must be provided"); + await expect( + deleteFile.call(driver, { path: '' }) + ).rejects.toThrow("'path' must be provided"); + expect(driver.sendPowerShellCommand).not.toHaveBeenCalled(); + }); + + it('sends Remove-Item with -Path for simple paths', async () => { + const driver = createMockDriver() as any; + await deleteFile.call(driver, { path: 'C:\\temp\\file.txt' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Remove-Item') + ); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('-Path \'C:\\temp\\file.txt\'') + ); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('-Force') + ); + }); + + it('uses -LiteralPath when path contains brackets', async () => { + const driver = createMockDriver() as any; + await deleteFile.call(driver, { path: 'C:\\temp\\file[1].txt' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('-LiteralPath') + ); + }); + + it('escapes single quotes in path', async () => { + const driver = createMockDriver() as any; + await deleteFile.call(driver, { path: "C:\\temp\\file's.txt" }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('file\'\'s.txt') + ); + }); +}); diff --git a/test/commands/extension/deleteFolder.test.ts b/test/commands/extension/deleteFolder.test.ts new file mode 100644 index 0000000..5d51bb1 --- /dev/null +++ b/test/commands/extension/deleteFolder.test.ts @@ -0,0 +1,43 @@ +/** + * Unit tests for deleteFolder extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { deleteFolder } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +describe('deleteFolder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when path is not provided', async () => { + const driver = createMockDriver() as any; + await expect( + deleteFolder.call(driver, {} as any) + ).rejects.toThrow("'path' must be provided"); + expect(driver.sendPowerShellCommand).not.toHaveBeenCalled(); + }); + + it('sends Remove-Item with -Recurse by default', async () => { + const driver = createMockDriver() as any; + await deleteFolder.call(driver, { path: 'C:\\temp\\folder' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('-Recurse') + ); + }); + + it('omits -Recurse when recursive is false', async () => { + const driver = createMockDriver() as any; + await deleteFolder.call(driver, { path: 'C:\\temp\\folder', recursive: false }); + const call = driver.sendPowerShellCommand.mock.calls[0][0]; + expect(call).not.toContain('-Recurse'); + }); + + it('uses -LiteralPath when path contains special chars', async () => { + const driver = createMockDriver() as any; + await deleteFolder.call(driver, { path: 'C:\\temp\\folder[1]' }); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('-LiteralPath') + ); + }); +}); diff --git a/test/commands/extension/execute.test.ts b/test/commands/extension/execute.test.ts new file mode 100644 index 0000000..f7e13b0 --- /dev/null +++ b/test/commands/extension/execute.test.ts @@ -0,0 +1,91 @@ +/** + * Unit tests for the execute command router. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as extension from '../../../lib/commands/extension'; +import { createMockDriver, MOCK_ELEMENT } from '../../fixtures/driver'; + +describe('execute (command router)', () => { + let driver: any; + + beforeEach(() => { + vi.clearAllMocks(); + driver = createMockDriver() as any; + Object.assign(driver, extension); + }); + + it('routes windows:launchApp to windowsLaunchApp', async () => { + driver.launchApp = vi.fn().mockResolvedValue(undefined); + await extension.execute.call(driver, 'windows: launchApp', []); + expect(driver.launchApp).toHaveBeenCalledOnce(); + }); + + it('routes windows:closeApp to windowsCloseApp', async () => { + driver.closeApp = vi.fn().mockResolvedValue(undefined); + await extension.execute.call(driver, 'windows: closeApp', []); + expect(driver.closeApp).toHaveBeenCalledOnce(); + }); + + it('routes windows:getDeviceTime with format arg', async () => { + driver.getDeviceTime = vi.fn().mockResolvedValue('2026'); + const result = await extension.execute.call(driver, 'windows: getDeviceTime', [{ format: 'yyyy' }]); + expect(driver.getDeviceTime).toHaveBeenCalledWith(undefined, 'yyyy'); + expect(result).toBe('2026'); + }); + + it('routes windows:getDeviceTime without format defaults to ISO 8601', async () => { + driver.getDeviceTime = vi.fn().mockResolvedValue('2026-03-03T10:00:00+00:00'); + const result = await extension.execute.call(driver, 'windows: getDeviceTime', []); + expect(driver.getDeviceTime).toHaveBeenCalledWith(undefined, undefined); + expect(result).toBe('2026-03-03T10:00:00+00:00'); + }); + + it('routes windows:deleteFile to deleteFile with args', async () => { + await extension.execute.call(driver, 'windows: deleteFile', [{ path: 'C:\\temp\\file.txt' }]); + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('Remove-Item') + ); + }); + + it('routes windows:invoke to patternInvoke with element', async () => { + await extension.execute.call(driver, 'windows: invoke', [MOCK_ELEMENT]); + // Command is base64-encoded; verify [InvokePattern] is used + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('W0ludm9rZVBhdHRlcm5d') + ); + }); + + it('throws UnknownCommandError for unknown windows command', async () => { + await expect( + extension.execute.call(driver, 'windows: unknownCommand', []) + ).rejects.toThrow('Unknown command'); + }); + + it('routes powerShell to executePowerShellScript', async () => { + driver.assertFeatureEnabled = vi.fn(); + driver.caps = {}; + driver.sendPowerShellCommand.mockResolvedValue('output'); + await extension.execute.call(driver, 'powerShell', ['Get-Process']); + expect(driver.assertFeatureEnabled).toHaveBeenCalledWith('power_shell'); + // Script is base64-encoded in pwsh wrapper; verify Get-Process is present + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('R2V0LVByb2Nlc3M') + ); + }); + + it('routes return window.name to sendPowerShellCommand', async () => { + driver.sendPowerShellCommand.mockResolvedValue('WindowName'); + const result = await extension.execute.call(driver, 'return window.name', []); + // Command is base64-encoded; verify it uses rootElement and fetches Name property + expect(driver.sendPowerShellCommand).toHaveBeenCalledWith( + expect.stringContaining('JHJvb3RFbGVtZW50') + ); + expect(result).toBe('WindowName'); + }); + + it('throws NotImplementedError for non-matching script', async () => { + await expect( + extension.execute.call(driver, 'unknownScript', []) + ).rejects.toThrow('Method is not implemented'); + }); +}); diff --git a/test/commands/extension/input.test.ts b/test/commands/extension/input.test.ts new file mode 100644 index 0000000..b5a940c --- /dev/null +++ b/test/commands/extension/input.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for executeKeys, executeClick, executeHover, executeScroll extension commands. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { executeKeys, executeClick, executeHover, executeScroll } from '../../../lib/commands/extension'; +import { createMockDriver } from '../../fixtures/driver'; + +vi.mock('../../../lib/winapi/user32', () => ({ + keyDown: vi.fn(), + keyUp: vi.fn(), + mouseDown: vi.fn(), + mouseUp: vi.fn(), + mouseMoveAbsolute: vi.fn().mockResolvedValue(undefined), + mouseScroll: vi.fn(), + sendKeyboardEvents: vi.fn(), +})); + +describe('executeKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when neither pause, text nor virtualKeyCode is set', async () => { + const driver = createMockDriver() as any; + await expect( + executeKeys.call(driver, { actions: {}, forceUnicode: false }) + ).rejects.toThrow('Either pause, text or virtualKeyCode should be set.'); + }); + + it('throws when multiple of pause, text, virtualKeyCode are set', async () => { + const driver = createMockDriver() as any; + await expect( + executeKeys.call(driver, { actions: { pause: 100, text: 'a' }, forceUnicode: false }) + ).rejects.toThrow('Either pause, text or virtualKeyCode should be set.'); + }); + + it('handles pause action', async () => { + const driver = createMockDriver() as any; + await executeKeys.call(driver, { actions: { pause: 50 }, forceUnicode: false }); + expect(driver.sendPowerShellCommand).not.toHaveBeenCalled(); + }); + + it('handles text action', async () => { + const driver = createMockDriver() as any; + const { keyDown, keyUp } = await import('../../../lib/winapi/user32'); + await executeKeys.call(driver, { actions: { text: 'a' }, forceUnicode: false }); + expect(keyDown).toHaveBeenCalled(); + expect(keyUp).toHaveBeenCalled(); + }); + + it('handles virtualKeyCode action', async () => { + const driver = createMockDriver() as any; + const { sendKeyboardEvents } = await import('../../../lib/winapi/user32'); + await executeKeys.call(driver, { actions: { virtualKeyCode: 0x41, down: true }, forceUnicode: false }); + expect(sendKeyboardEvents).toHaveBeenCalled(); + }); +}); + +describe('executeClick', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when only x is provided without y', async () => { + const driver = createMockDriver() as any; + await expect( + executeClick.call(driver, { x: 100 }) + ).rejects.toThrow('Both x and y must be provided'); + }); + + it('clicks at coordinates when x and y provided', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + const { mouseMoveAbsolute, mouseDown, mouseUp } = await import('../../../lib/winapi/user32'); + await executeClick.call(driver, { x: 100, y: 200 }); + expect(mouseMoveAbsolute).toHaveBeenCalledWith(100, 200, 0); + expect(mouseDown).toHaveBeenCalled(); + expect(mouseUp).toHaveBeenCalled(); + }); + + it('clicks with elementId when element exists', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + const rectJson = '{"x":10,"y":20,"width":100,"height":50}'; + let callCount = 0; + driver.sendPowerShellCommand.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve('True'); + } + if (callCount === 2) { + return Promise.resolve('1.2.3.4.5'); + } + return Promise.resolve(rectJson); + }); + const { mouseMoveAbsolute } = await import('../../../lib/winapi/user32'); + await executeClick.call(driver, { elementId: '1.2.3.4.5' }); + expect(mouseMoveAbsolute).toHaveBeenCalledWith(60, 45, 0); // center of rect + }); +}); + +describe('executeHover', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when only startX is provided without startY', async () => { + const driver = createMockDriver() as any; + await expect( + executeHover.call(driver, { startX: 100 }) + ).rejects.toThrow('Both startX and startY must be provided'); + }); + + it('throws when only endX is provided without endY', async () => { + const driver = createMockDriver() as any; + await expect( + executeHover.call(driver, { startX: 0, startY: 0, endX: 100 }) + ).rejects.toThrow('Both endX and endY must be provided'); + }); + + it('moves from start to end coordinates', async () => { + const driver = createMockDriver() as any; + (driver as any).caps = {}; + const { mouseMoveAbsolute } = await import('../../../lib/winapi/user32'); + await executeHover.call(driver, { startX: 0, startY: 0, endX: 100, endY: 100 }); + expect(mouseMoveAbsolute).toHaveBeenCalledTimes(2); + }); +}); + +describe('executeScroll', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when elementId and x/y are both provided', async () => { + const driver = createMockDriver() as any; + await expect( + executeScroll.call(driver, { elementId: '1.2.3.4.5', x: 100, y: 100 }) + ).rejects.toThrow('Either elementId or x and y must be provided'); + }); + + it('throws when only x is provided without y', async () => { + const driver = createMockDriver() as any; + await expect( + executeScroll.call(driver, { x: 100 }) + ).rejects.toThrow('Both x and y must be provided'); + }); + + it('scrolls at coordinates when x, y, deltaX, deltaY provided', async () => { + const driver = createMockDriver() as any; + const { mouseMoveAbsolute, mouseScroll } = await import('../../../lib/winapi/user32'); + await executeScroll.call(driver, { x: 100, y: 200, deltaX: 0, deltaY: 50 }); + expect(mouseMoveAbsolute).toHaveBeenCalledWith(100, 200, 0); + expect(mouseScroll).toHaveBeenCalledWith(0, 50); + }); +}); diff --git a/test/commands/extension/pattern.test.ts b/test/commands/extension/pattern.test.ts new file mode 100644 index 0000000..b082097 --- /dev/null +++ b/test/commands/extension/pattern.test.ts @@ -0,0 +1,123 @@ +/** + * Unit tests for pattern extension commands (invoke, expand, collapse, close, etc.). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + patternInvoke, + patternExpand, + patternCollapse, + patternScrollIntoView, + patternClose, + patternMaximize, + patternMinimize, + patternRestore, + patternIsMultiple, + patternGetSelectedItem, + patternGetAllSelectedItems, + patternAddToSelection, + patternRemoveFromSelection, + patternSelect, + patternToggle, + patternSetValue, + patternGetValue, + focusElement, +} from '../../../lib/commands/extension'; +import { W3C_ELEMENT_KEY } from '@appium/base-driver'; +import { createMockDriver, MOCK_ELEMENT } from '../../fixtures/driver'; + +const PATTERN_COMMANDS = [ + { name: 'patternInvoke', fn: patternInvoke, expectInCommand: 'InvokePattern' }, + { name: 'patternExpand', fn: patternExpand, expectInCommand: 'ExpandCollapsePattern' }, + { name: 'patternCollapse', fn: patternCollapse, expectInCommand: 'Collapse' }, + { name: 'patternScrollIntoView', fn: patternScrollIntoView, expectInCommand: 'ScrollItemPattern' }, + { name: 'patternClose', fn: patternClose, expectInCommand: 'WindowPattern' }, + { name: 'patternMaximize', fn: patternMaximize, expectInCommand: 'Maximized' }, + { name: 'patternMinimize', fn: patternMinimize, expectInCommand: 'Minimized' }, + { name: 'patternRestore', fn: patternRestore, expectInCommand: 'Normal' }, + { name: 'patternAddToSelection', fn: patternAddToSelection, expectInCommand: 'AddToSelection' }, + { name: 'patternRemoveFromSelection', fn: patternRemoveFromSelection, expectInCommand: 'RemoveFromSelection' }, + { name: 'patternSelect', fn: patternSelect, expectInCommand: 'Select' }, + { name: 'patternToggle', fn: patternToggle, expectInCommand: 'TogglePattern' }, +] as const; + +describe('pattern commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(PATTERN_COMMANDS)('$name sends sendPowerShellCommand with element id and correct command', async ({ fn, expectInCommand }) => { + const driver = createMockDriver() as any; + await fn.call(driver, MOCK_ELEMENT); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + const callArg = driver.sendPowerShellCommand.mock.calls[0][0]; + const base64Match = callArg.match(/FromBase64String\('([^']+)'\)/); + const decoded = base64Match ? Buffer.from(base64Match[1], 'base64').toString() : callArg; + expect(decoded).toContain(expectInCommand); + }); + + it('patternIsMultiple returns true when result is true', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('true'); + const result = await patternIsMultiple.call(driver, MOCK_ELEMENT); + expect(result).toBe(true); + }); + + it('patternIsMultiple returns false when result is false', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('false'); + const result = await patternIsMultiple.call(driver, MOCK_ELEMENT); + expect(result).toBe(false); + }); + + it('patternGetSelectedItem returns element when selection exists', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('2.3.4.5.6'); + const result = await patternGetSelectedItem.call(driver, MOCK_ELEMENT); + expect(result).toEqual({ [W3C_ELEMENT_KEY]: '2.3.4.5.6' }); + }); + + it('patternGetSelectedItem throws when no selection', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue(''); + await expect(patternGetSelectedItem.call(driver, MOCK_ELEMENT)).rejects.toThrow(); + }); + + it('patternGetAllSelectedItems returns array of elements', async () => { + const driver = createMockDriver() as any; + driver.sendPowerShellCommand.mockResolvedValue('2.3.4.5.6\n3.4.5.6.7'); + const result = await patternGetAllSelectedItems.call(driver, MOCK_ELEMENT); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ [W3C_ELEMENT_KEY]: '2.3.4.5.6' }); + }); + + it('patternSetValue sends setValue or setRangeValue command', async () => { + const driver = createMockDriver() as any; + await patternSetValue.call(driver, MOCK_ELEMENT, 'test value'); + const callArg = driver.sendPowerShellCommand.mock.calls[0][0]; + const base64Match = callArg.match(/FromBase64String\('([^']+)'\)/); + const decoded = Buffer.from(base64Match?.[1] ?? '', 'base64').toString(); + expect(decoded).toMatch(/ValuePattern|RangeValuePattern/); + expect(decoded).toMatch(/SetValue/); + }); + + it('patternGetValue sends getValue command', async () => { + const driver = createMockDriver() as any; + await patternGetValue.call(driver, MOCK_ELEMENT); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + const callArg = driver.sendPowerShellCommand.mock.calls[0][0]; + const base64Match = callArg.match(/FromBase64String\('([^']+)'\)/); + const decoded = Buffer.from(base64Match?.[1] ?? '', 'base64').toString(); + expect(decoded).toContain('ValuePattern'); + expect(decoded).toContain('.Value'); + }); + + it('focusElement sends setFocus command', async () => { + const driver = createMockDriver() as any; + await focusElement.call(driver, MOCK_ELEMENT); + expect(driver.sendPowerShellCommand).toHaveBeenCalledTimes(1); + const callArg = driver.sendPowerShellCommand.mock.calls[0][0]; + const base64Match = callArg.match(/FromBase64String\('([^']+)'\)/); + const decoded = Buffer.from(base64Match?.[1] ?? '', 'base64').toString(); + expect(decoded).toContain('SetFocus'); + }); +}); diff --git a/test/commands/extension/startRecordingScreen.test.ts b/test/commands/extension/startRecordingScreen.test.ts new file mode 100644 index 0000000..457e7c6 --- /dev/null +++ b/test/commands/extension/startRecordingScreen.test.ts @@ -0,0 +1,124 @@ +/** + * Unit tests for startRecordingScreen extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock(import('node:path'), async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +import { startRecordingScreen } from '../../../lib/commands/extension'; +import { ScreenRecorder } from '../../../lib/commands/screen-recorder'; +import { createMockDriver } from '../../fixtures/driver'; + +vi.mock('../../../lib/commands/screen-recorder', () => { + const MockScreenRecorder = vi.fn(); + return { + ScreenRecorder: MockScreenRecorder, + DEFAULT_EXT: 'mp4', + uploadRecordedMedia: vi.fn(), + }; +}); + +const MockScreenRecorder = vi.mocked(ScreenRecorder); + +describe('startRecordingScreen', () => { + let mockRecorderInstance: { isRunning: ReturnType; start: ReturnType; stop: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockRecorderInstance = { + isRunning: vi.fn().mockReturnValue(false), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(''), + }; + MockScreenRecorder.mockImplementation(() => mockRecorderInstance as any); + }); + + it('creates a ScreenRecorder and starts recording', async () => { + const driver = createMockDriver() as any; + driver._screenRecorder = null; + + await startRecordingScreen.call(driver, { outputPath: 'C:\\temp\\rec.mp4' }); + + expect(MockScreenRecorder).toHaveBeenCalledWith( + 'C:\\temp\\rec.mp4', + driver.log, + expect.any(Object), + ); + expect(mockRecorderInstance.start).toHaveBeenCalledOnce(); + expect(driver._screenRecorder).toBe(mockRecorderInstance); + }); + + it('passes options to ScreenRecorder', async () => { + const driver = createMockDriver() as any; + driver._screenRecorder = null; + + await startRecordingScreen.call(driver, { + outputPath: 'C:\\rec.mp4', + timeLimit: 60, + videoFps: 30, + preset: 'ultrafast', + captureCursor: true, + captureClicks: true, + audioInput: 'Microphone', + videoFilter: 'scale=1280:-2', + }); + + expect(MockScreenRecorder).toHaveBeenCalledWith( + 'C:\\rec.mp4', + driver.log, + expect.objectContaining({ + fps: 30, + timeLimit: 60, + preset: 'ultrafast', + captureCursor: true, + captureClicks: true, + audioInput: 'Microphone', + videoFilter: 'scale=1280:-2', + }), + ); + }); + + it('does nothing when already recording and forceRestart=false', async () => { + const driver = createMockDriver() as any; + const existingRecorder = { + isRunning: vi.fn().mockReturnValue(true), + stop: vi.fn(), + }; + driver._screenRecorder = existingRecorder; + + await startRecordingScreen.call(driver, { forceRestart: false }); + + expect(existingRecorder.stop).not.toHaveBeenCalled(); + expect(MockScreenRecorder).not.toHaveBeenCalled(); + }); + + it('force-stops existing recording when forceRestart=true (default)', async () => { + const driver = createMockDriver() as any; + const existingRecorder = { + isRunning: vi.fn().mockReturnValue(true), + stop: vi.fn().mockResolvedValue(''), + }; + driver._screenRecorder = existingRecorder; + + await startRecordingScreen.call(driver, { outputPath: 'C:\\new.mp4' }); + + expect(existingRecorder.stop).toHaveBeenCalledWith(true); + expect(MockScreenRecorder).toHaveBeenCalled(); + expect(mockRecorderInstance.start).toHaveBeenCalled(); + }); + + it('clears _screenRecorder if start() throws', async () => { + const driver = createMockDriver() as any; + driver._screenRecorder = null; + mockRecorderInstance.start.mockRejectedValue(new Error('ffmpeg failed')); + + await expect( + startRecordingScreen.call(driver, { outputPath: 'C:\\out.mp4' }) + ).rejects.toThrow('ffmpeg failed'); + + expect(driver._screenRecorder).toBeNull(); + }); +}); diff --git a/test/commands/extension/stopRecordingScreen.test.ts b/test/commands/extension/stopRecordingScreen.test.ts new file mode 100644 index 0000000..586f464 --- /dev/null +++ b/test/commands/extension/stopRecordingScreen.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for stopRecordingScreen extension command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { stopRecordingScreen } from '../../../lib/commands/extension'; +import { uploadRecordedMedia } from '../../../lib/commands/screen-recorder'; +import { createMockDriver } from '../../fixtures/driver'; + +vi.mock('../../../lib/commands/screen-recorder', () => ({ + ScreenRecorder: vi.fn(), + DEFAULT_EXT: 'mp4', + uploadRecordedMedia: vi.fn(), +})); + +const mockUploadRecordedMedia = vi.mocked(uploadRecordedMedia); + +describe('stopRecordingScreen', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUploadRecordedMedia.mockResolvedValue('base64data'); + }); + + it('returns empty string when no recording in progress', async () => { + const driver = createMockDriver() as any; + driver._screenRecorder = null; + + const result = await stopRecordingScreen.call(driver); + + expect(result).toBe(''); + }); + + it('returns base64 video content', async () => { + const driver = createMockDriver() as any; + const mockRecorder = { stop: vi.fn().mockResolvedValue('C:\\temp\\rec.mp4') }; + driver._screenRecorder = mockRecorder; + mockUploadRecordedMedia.mockResolvedValue('dmlkZW8tZGF0YQ=='); + + const result = await stopRecordingScreen.call(driver); + + expect(mockRecorder.stop).toHaveBeenCalledWith(); + expect(mockUploadRecordedMedia).toHaveBeenCalledWith( + 'C:\\temp\\rec.mp4', + undefined, + expect.any(Object), + ); + expect(result).toBe('dmlkZW8tZGF0YQ=='); + }); + + it('returns empty string when stop() returns no file path', async () => { + const driver = createMockDriver() as any; + const mockRecorder = { stop: vi.fn().mockResolvedValue('') }; + driver._screenRecorder = mockRecorder; + + const result = await stopRecordingScreen.call(driver); + + expect(result).toBe(''); + expect(mockUploadRecordedMedia).not.toHaveBeenCalled(); + }); + + it('passes remotePath and upload options to uploadRecordedMedia', async () => { + const driver = createMockDriver() as any; + const mockRecorder = { stop: vi.fn().mockResolvedValue('C:\\temp\\rec.mp4') }; + driver._screenRecorder = mockRecorder; + + await stopRecordingScreen.call(driver, { + remotePath: 'https://example.com/upload', + user: 'admin', + pass: 'secret', + }); + + expect(mockUploadRecordedMedia).toHaveBeenCalledWith( + 'C:\\temp\\rec.mp4', + 'https://example.com/upload', + expect.objectContaining({ user: 'admin', pass: 'secret' }), + ); + }); +}); diff --git a/test/e2e/actions.e2e.ts b/test/e2e/actions.e2e.ts new file mode 100644 index 0000000..1eb3a33 --- /dev/null +++ b/test/e2e/actions.e2e.ts @@ -0,0 +1,132 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { + createCalculatorSession, + createNotepadSession, + getNotepadTextArea, + quitSession, + resetCalculator, + clearNotepad, +} from './helpers/session.js'; + +describe('W3C Actions API', () => { + let calc: Browser; + let notepad: Browser; + + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + beforeEach(async () => { + await resetCalculator(calc); + }); + + describe('key actions (keyDown / keyUp / pause)', () => { + it('types a digit into Calculator via keyDown/keyUp sequence', async () => { + await calc.action('key') + .down('6') + .up('6') + .perform(); + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('6'); + }); + + it('holds Shift then types a letter in Notepad to produce uppercase', async () => { + notepad = await createNotepadSession(); + await clearNotepad(notepad); + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + await notepad.action('key') + .down('\uE008') // Shift key + .down('b') + .up('b') + .up('\uE008') + .perform(); + const text = await textArea.getText(); + expect(text).toContain('B'); + await notepad.keys(['\uE003']); // delete 'B' so Notepad closes without a save prompt + await quitSession(notepad); + }); + + it('pause action in key sequence does not throw', async () => { + await expect( + calc.action('key') + .down('1') + .pause(100) + .up('1') + .perform() + ).resolves.not.toThrow(); + }); + + it('null key (\\uE000) releases all held modifiers without error', async () => { + await expect( + calc.action('key') + .down('\uE008') // Shift + .down('\uE000') // Null — releases all + .perform() + ).resolves.not.toThrow(); + }); + }); + + describe('pointer actions (mouse)', () => { + it('moves to a button center and clicks via pointer sequence', async () => { + const btn = await calc.$('~num4Button'); + const loc = await btn.getLocation(); + const size = await btn.getSize(); + const cx = Math.round(loc.x + size.width / 2); + const cy = Math.round(loc.y + size.height / 2); + + await calc.action('pointer') + .move({ x: cx, y: cy }) + .down() + .up() + .perform(); + + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('4'); + }); + + it('performs a double-click via two down/up cycles', async () => { + const btn = await calc.$('~num5Button'); + calc.action('pointer') + .move({ origin: btn }) + .down() + .up() + .down() + .up() + .perform(); + + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('55'); + + }); + + it('right-click using button: 2 in pointer down', async () => { + const btn = await calc.$('~num1Button'); + await expect( + calc.action('pointer') + .move({ origin: btn }) + .down({ button: 2 }) + .up({ button: 2 }) + .perform() + ).resolves.not.toThrow(); + }); + + it('drags from one button to another via pointerDown, pointerMove, pointerUp', async () => { + const startBtn = await calc.$('~num1Button'); + const endBtn = await calc.$('~num2Button'); + await expect( + calc.action('pointer') + .move({ origin: startBtn }) + .down() + .move({ origin: endBtn, duration: 300 }) + .up() + .perform() + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/test/e2e/device-system.e2e.ts b/test/e2e/device-system.e2e.ts new file mode 100644 index 0000000..85bec94 --- /dev/null +++ b/test/e2e/device-system.e2e.ts @@ -0,0 +1,42 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { createCalculatorSession, quitSession } from './helpers/session.js'; + +describe('Device and system commands', () => { + let driver: Browser; + + beforeAll(async () => { + driver = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(driver); + }); + + describe('getDeviceTime', () => { + it('returns an ISO 8601 timestamp string', async () => { + const time = await driver.getDeviceTime(); + // yyyy-MM-ddTHH:mm:ss+HH:mm + expect(time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); + }); + + it('windows: getDeviceTime with custom format returns formatted string', async () => { + const year = await driver.executeScript('windows: getDeviceTime', [{ format: 'yyyy' }]) as string; + expect(year).toMatch(/^\d{4}$/); + expect(parseInt(year, 10)).toBeGreaterThanOrEqual(2020); + }); + }); + + describe('getOrientation', () => { + it('returns LANDSCAPE or PORTRAIT', async () => { + const orientation = await driver.getOrientation(); + expect(['LANDSCAPE', 'PORTRAIT']).toContain(orientation); + }); + + it('returns the same value on repeated calls', async () => { + const first = await driver.getOrientation(); + const second = await driver.getOrientation(); + expect(first).toBe(second); + }); + }); +}); diff --git a/test/e2e/element-commands.e2e.ts b/test/e2e/element-commands.e2e.ts new file mode 100644 index 0000000..e823ea1 --- /dev/null +++ b/test/e2e/element-commands.e2e.ts @@ -0,0 +1,154 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { + createCalculatorSession, + createNotepadSession, + getNotepadTextArea, + quitSession, + resetCalculator, + clearNotepad, +} from './helpers/session.js'; + +describe('W3C element commands', () => { + let calc: Browser; + let notepad: Browser; + + beforeAll(async () => { + calc = await createCalculatorSession(); + notepad = await createNotepadSession(); + }); + + afterAll(async () => { + await quitSession(calc); + await quitSession(notepad); + }); + + beforeEach(async () => { + await resetCalculator(calc); + }); + + describe('getProperty / getAttribute', () => { + it('gets the Name property of the result display element', async () => { + const name = await calc.$('~CalculatorResults').getAttribute('Name'); + expect(name).toBeTruthy(); + }); + + it('gets the AutomationId property of a button', async () => { + const automationId = await calc.$('~num1Button').getAttribute('AutomationId'); + expect(automationId).toBe('num1Button'); + }); + + it('gets the IsEnabled property of a button', async () => { + const isEnabled = await calc.$('~equalButton').getAttribute('IsEnabled'); + expect(isEnabled).toBeTruthy(); + }); + + it('gets the ControlType property of a button', async () => { + const controlType = await calc.$('~num1Button').getAttribute('ControlType'); + expect(controlType).toBeTruthy(); + }); + }); + + describe('getText', () => { + it('returns text content of the result display after pressing a digit', async () => { + await calc.$('~num5Button').click(); + const text = await calc.$('~CalculatorResults').getText(); + expect(text).toContain('5'); + }); + + it('returns a string for an element', async () => { + const text = await calc.$('~num1Button').getText(); + expect(typeof text).toBe('string'); + }); + }); + + describe('getName', () => { + it('returns the control type name for a Button element', async () => { + const name = await calc.$('~num1Button').getTagName(); + expect(name).toBeTruthy(); + }); + }); + + describe('getElementRect', () => { + it('returns a rect with positive width and height for a visible button', async () => { + const rect = await calc.$('~num1Button').getSize(); + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + }); + + it('returns x and y coordinates', async () => { + const location = await calc.$('~num1Button').getLocation(); + expect(typeof location.x).toBe('number'); + expect(typeof location.y).toBe('number'); + }); + }); + + describe('elementDisplayed', () => { + it('returns true for a visible button', async () => { + expect(await calc.$('~num1Button').isDisplayed()).toBe(true); + }); + }); + + describe('elementEnabled', () => { + it('returns true for an enabled button', async () => { + expect(await calc.$('~equalButton').isEnabled()).toBe(true); + }); + }); + + describe('elementSelected', () => { + it('returns true for the active navigation mode item (Standard)', async () => { + await calc.$('~TogglePaneButton').click(); + try { + expect(await calc.$('~Standard').isSelected()).toBe(true); + } finally { + await calc.$('~TogglePaneButton').click(); + } + }); + }); + + describe('active', () => { + it('returns the currently focused element after clicking a button', async () => { + await calc.$('~num3Button').click(); + const active = await calc.getActiveElement(); + expect(active).toBeDefined(); + }); + }); + + describe('click', () => { + it('clicking digit buttons produces the expected result in the display', async () => { + await calc.$('~num7Button').click(); + const text = await calc.$('~CalculatorResults').getText(); + expect(text).toContain('7'); + }); + + it('performs addition: 1 + 1 = 2', async () => { + await calc.$('~num1Button').click(); + await calc.$('~plusButton').click(); + await calc.$('~num1Button').click(); + await calc.$('~equalButton').click(); + const text = await calc.$('~CalculatorResults').getText(); + expect(text).toContain('2'); + }); + }); + + describe('setValue and clear', () => { + beforeEach(async () => { + await clearNotepad(notepad); + }); + + it('sets a value in Notepad text area and getText returns it', async () => { + const textArea = await getNotepadTextArea(notepad); + await textArea.setValue('Hello World'); + const text = await textArea.getText(); + expect(text).toContain('Hello World'); + }); + + it('clear empties the Notepad text area', async () => { + const textArea = await getNotepadTextArea(notepad); + await textArea.setValue('some text'); + await textArea.clearValue(); + const text = await textArea.getText(); + expect(text.trim()).toBe(''); + }); + }); +}); diff --git a/test/e2e/extension-app-lifecycle.e2e.ts b/test/e2e/extension-app-lifecycle.e2e.ts new file mode 100644 index 0000000..d676f15 --- /dev/null +++ b/test/e2e/extension-app-lifecycle.e2e.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createCalculatorSession, + quitSession, + CALCULATOR_APP_ID, + closeAllTestApps, +} from './helpers/session.js'; + +// Each test creates its own session because app lifecycle commands mutate session state. + +describe('App lifecycle commands', () => { + afterEach(() => { + closeAllTestApps; + }); + describe('windows: launchApp and closeApp cycle', () => { + it('windows: launchApp launches a new app instance and display shows 0', async () => { + const driver = await createCalculatorSession(); + try { + await driver.executeScript('windows: launchApp', []); + const display = await driver.$('~CalculatorResults'); + const text = await display.getText(); + expect(text).toContain('0'); + } finally { + await quitSession(driver); + } + }); + + it('windows: closeApp closes the active window', async () => { + const driver = await createCalculatorSession({ 'appium:shouldCloseApp': false }); + try { + const handleBefore = await driver.getWindowHandle(); + await driver.executeScript('windows: closeApp', []); + const handles = await driver.getWindowHandles(); + expect(handles).not.toContain(handleBefore); + } finally { + await quitSession(driver); + } + }); + + it('windows: closeApp then windows: launchApp restores a usable session', async () => { + const driver = await createCalculatorSession(); + try { + await driver.executeScript('windows: closeApp', []); + await driver.executeScript('windows: launchApp', []); + const display = await driver.$('~CalculatorResults'); + expect(await display.isExisting()).toBe(true); + } finally { + await quitSession(driver); + } + }); + + it('multiple windows: closeApp calls do not crash the session', async () => { + const driver = await createCalculatorSession({ 'appium:shouldCloseApp': false }); + try { + await driver.executeScript('windows: closeApp', []); + // Second close should throw (app already closed) + await expect( + driver.executeScript('windows: closeApp', []) + ).rejects.toThrow(); + } finally { + await quitSession(driver); + } + }); + }); + + describe('shouldCloseApp capability', () => { + it('shouldCloseApp: true closes Calculator after deleteSession', async () => { + const driver = await createCalculatorSession({ 'appium:shouldCloseApp': true }); + const handle = await driver.getWindowHandle(); + await driver.deleteSession(); + + // Verify via a Root session that the Calculator window is gone + const rootDriver = await (await import('./helpers/session.js')).createRootSession(); + try { + const handles = await rootDriver.getWindowHandles(); + expect(handles).not.toContain(handle); + } finally { + await quitSession(rootDriver); + } + }); + + it('shouldCloseApp: false leaves Calculator running after deleteSession', async () => { + const driver = await createCalculatorSession({ + 'appium:shouldCloseApp': false, + 'appium:app': CALCULATOR_APP_ID, + }); + const handle = await driver.getWindowHandle(); + await driver.deleteSession(); + + // Verify via a Root session that the Calculator window is still open + const rootDriver = await (await import('./helpers/session.js')).createRootSession(); + try { + const handles = await rootDriver.getWindowHandles(); + expect(handles).toContain(handle); + } finally { + // Clean up: kill the orphaned Calculator + const newSession = await (await import('./helpers/session.js')).createCalculatorSession(); + await quitSession(newSession); + await quitSession(rootDriver); + } + }); + }); + + describe('ms:forcequit capability', () => { + it('ms:forcequit: true forcefully terminates the Calculator process on quit', async () => { + const driver = await createCalculatorSession({ + 'appium:shouldCloseApp': true, + 'ms:forcequit': true, + }); + // Simply verify session creation and quit succeeds + expect(await driver.getWindowHandle()).toBeTruthy(); + await expect(driver.deleteSession()).resolves.not.toThrow(); + }); + }); + + describe('prerun / postrun scripts', () => { + it('prerun script is executed before app launch (writes a marker file)', async () => { + const markerPath = join(tmpdir(), `novawindows-prerun-${Date.now()}.txt`); + const driver = await createCalculatorSession({ + 'appium:prerun': { + script: `New-Item -ItemType File -Path "${markerPath.replace(/\\/g, '\\\\')}" -Force | Out-Null`, + }, + }); + try { + expect(existsSync(markerPath)).toBe(true); + } finally { + await quitSession(driver); + if (existsSync(markerPath)) { unlinkSync(markerPath); } + } + }); + + it('postrun script is executed after session deletion (writes a marker file)', async () => { + const markerPath = join(tmpdir(), `novawindows-postrun-${Date.now()}.txt`); + const driver = await createCalculatorSession({ + 'appium:postrun': { + script: `New-Item -ItemType File -Path "${markerPath.replace(/\\/g, '\\\\')}" -Force | Out-Null`, + }, + }); + await driver.deleteSession(); + // Give postrun a moment to execute + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(existsSync(markerPath)).toBe(true); + if (existsSync(markerPath)) { unlinkSync(markerPath); } + }); + }); +}); diff --git a/test/e2e/extension-cache-request.e2e.ts b/test/e2e/extension-cache-request.e2e.ts new file mode 100644 index 0000000..c22bb2b --- /dev/null +++ b/test/e2e/extension-cache-request.e2e.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { createCalculatorSession, quitSession } from './helpers/session.js'; + +// Each test creates its own session because cacheRequest modifies global PowerShell session state. + +describe('windows: cacheRequest', () => { + it('pushes a cacheRequest with treeScope: SubTree without error', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ treeScope: 'SubTree' }]) + ).resolves.not.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('pushes a cacheRequest with treeFilter: RawView without error', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ treeFilter: 'RawView' }]) + ).resolves.not.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('pushes a cacheRequest with automationElementMode: Full without error', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ automationElementMode: 'Full' }]) + ).resolves.not.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('pushes a cacheRequest with all three properties set', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ + treeScope: 'SubTree', + treeFilter: 'RawView', + automationElementMode: 'Full', + }]) + ).resolves.not.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('throws InvalidArgumentError when no property is provided', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{}]) + ).rejects.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('throws InvalidArgumentError for an invalid treeScope value', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ treeScope: 'InvalidScope' }]) + ).rejects.toThrow(); + } finally { + await quitSession(driver); + } + }); + + it('throws InvalidArgumentError for an invalid automationElementMode value', async () => { + const driver = await createCalculatorSession(); + try { + await expect( + driver.executeScript('windows: cacheRequest', [{ automationElementMode: 'InvalidMode' }]) + ).rejects.toThrow(); + } finally { + await quitSession(driver); + } + }); +}); diff --git a/test/e2e/extension-clipboard.e2e.ts b/test/e2e/extension-clipboard.e2e.ts new file mode 100644 index 0000000..80a5099 --- /dev/null +++ b/test/e2e/extension-clipboard.e2e.ts @@ -0,0 +1,78 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { createNotepadSession, getNotepadTextArea, quitSession, clearNotepad } from './helpers/session.js'; + +// Minimal 1×1 transparent PNG as base64 +const TINY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +describe('windows: clipboard extension commands', () => { + let notepad: Browser; + + beforeAll(async () => { + notepad = await createNotepadSession(); + }); + + afterAll(async () => { + await quitSession(notepad); + }); + + beforeEach(async () => { + await clearNotepad(notepad); + }); + + describe('setClipboard + getClipboard (plaintext)', () => { + it('sets plaintext clipboard and getClipboard returns the same text (roundtrip)', async () => { + const text = 'clipboard roundtrip test'; + const b64In = Buffer.from(text).toString('base64'); + await notepad.executeScript('windows: setClipboard', [{ contentType: 'plaintext', b64Content: b64In }]); + const b64Out = await notepad.executeScript('windows: getClipboard', [{ contentType: 'plaintext' }]) as string; + const textOut = Buffer.from(b64Out, 'base64').toString(); + expect(textOut).toContain(text); + }); + + it('sets clipboard with explicit contentType: plaintext', async () => { + const b64 = Buffer.from('explicit type test').toString('base64'); + await expect( + notepad.executeScript('windows: setClipboard', [{ contentType: 'plaintext', b64Content: b64 }]) + ).resolves.not.toThrow(); + }); + + it('clipboard value survives between get calls (unchanged)', async () => { + const text = 'stable clipboard value'; + const b64 = Buffer.from(text).toString('base64'); + await notepad.executeScript('windows: setClipboard', [{ contentType: 'plaintext', b64Content: b64 }]); + const first = await notepad.executeScript('windows: getClipboard', [{ contentType: 'plaintext' }]) as string; + const second = await notepad.executeScript('windows: getClipboard', [{ contentType: 'plaintext' }]) as string; + expect(first).toBe(second); + }); + }); + + describe('setClipboard + getClipboard (image)', () => { + it('sets an image clipboard and getClipboard with image type returns non-empty', async () => { + await notepad.executeScript('windows: setClipboard', [{ + contentType: 'image', + b64Content: TINY_PNG_BASE64, + }]); + const result = await notepad.executeScript('windows: getClipboard', [{ contentType: 'image' }]) as string; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('clipboard integration with Notepad', () => { + it('sets clipboard text, Ctrl+V pastes it into Notepad, getText shows pasted content', async () => { + const pasteText = 'pasted from clipboard'; + const b64 = Buffer.from(pasteText).toString('base64'); + await notepad.executeScript('windows: setClipboard', [{ contentType: 'plaintext', b64Content: b64 }]); + + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + // Ctrl+V to paste + await notepad.keys(['Control', 'v']); + + const text = await textArea.getText(); + expect(text).toContain(pasteText); + }); + }); +}); diff --git a/test/e2e/extension-filesystem.e2e.ts b/test/e2e/extension-filesystem.e2e.ts new file mode 100644 index 0000000..9aa24eb --- /dev/null +++ b/test/e2e/extension-filesystem.e2e.ts @@ -0,0 +1,104 @@ +import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, rmdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Browser } from 'webdriverio'; +import { createCalculatorSession, quitSession } from './helpers/session.js'; + +describe('windows: deleteFile and deleteFolder', () => { + let driver: Browser; + + beforeAll(async () => { + driver = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(driver); + }); + + describe('windows: deleteFile', () => { + let testFilePath: string; + + beforeEach(() => { + testFilePath = join(tmpdir(), `novawindows-test-${Date.now()}.txt`); + writeFileSync(testFilePath, 'test content'); + }); + + afterEach(() => { + if (existsSync(testFilePath)) { + // Clean up if test didn't delete it + try { + rmdirSync(testFilePath); + } catch { + // file not a dir + } + } + }); + + it('deletes an existing temp file and the file no longer exists', async () => { + expect(existsSync(testFilePath)).toBe(true); + await driver.executeScript('windows: deleteFile', [{ path: testFilePath }]); + expect(existsSync(testFilePath)).toBe(false); + }); + + it('throws when the file does not exist', async () => { + const nonExistent = join(tmpdir(), 'novawindows-nonexistent-xyz.txt'); + await expect( + driver.executeScript('windows: deleteFile', [{ path: nonExistent }]) + ).rejects.toThrow(); + }); + + it('throws when path is not provided', async () => { + await expect( + driver.executeScript('windows: deleteFile', [{}]) + ).rejects.toThrow(); + }); + }); + + describe('windows: deleteFolder', () => { + let testDirPath: string; + + beforeEach(() => { + testDirPath = join(tmpdir(), `novawindows-dir-${Date.now()}`); + }); + + afterEach(() => { + // Best-effort cleanup in case test failed + if (existsSync(testDirPath)) { + try { + rmdirSync(testDirPath, { recursive: true }); + } catch { + // noop + } + } + }); + + it('deletes an existing empty temp directory', async () => { + mkdirSync(testDirPath); + expect(existsSync(testDirPath)).toBe(true); + await driver.executeScript('windows: deleteFolder', [{ path: testDirPath }]); + expect(existsSync(testDirPath)).toBe(false); + }); + + it('deletes a directory with files recursively (recursive: true, default)', async () => { + mkdirSync(testDirPath); + writeFileSync(join(testDirPath, 'file1.txt'), 'content'); + writeFileSync(join(testDirPath, 'file2.txt'), 'content'); + await driver.executeScript('windows: deleteFolder', [{ path: testDirPath, recursive: true }]); + expect(existsSync(testDirPath)).toBe(false); + }); + + it('throws when the folder does not exist', async () => { + const nonExistent = join(tmpdir(), 'novawindows-nonexistent-dir-xyz'); + await expect( + driver.executeScript('windows: deleteFolder', [{ path: nonExistent }]) + ).rejects.toThrow(); + }); + + it('throws when path is not provided', async () => { + await expect( + driver.executeScript('windows: deleteFolder', [{}]) + ).rejects.toThrow(); + }); + }); +}); diff --git a/test/e2e/extension-input.e2e.ts b/test/e2e/extension-input.e2e.ts new file mode 100644 index 0000000..300e0f2 --- /dev/null +++ b/test/e2e/extension-input.e2e.ts @@ -0,0 +1,262 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { + createCalculatorSession, + createNotepadSession, + getNotepadTextArea, + quitSession, + resetCalculator, + clearNotepad, +} from './helpers/session.js'; + +// VirtualKeyCode for Delete +const VK_DELETE = 0x2e; +// VirtualKeyCode for Shift +const VK_SHIFT = 0x10; + +describe('windows: keys, click, hover, scroll, clickAndDrag extension commands', () => { + let calc: Browser; + let notepad: Browser; + + beforeAll(async () => { + calc = await createCalculatorSession(); + notepad = await createNotepadSession(); + }); + + afterAll(async () => { + await quitSession(calc); + await quitSession(notepad); + }); + + beforeEach(async () => { + await resetCalculator(calc); + }); + + describe('windows: keys — text input', () => { + it('types "123" via text action into Calculator and display shows 123', async () => { + await calc.executeScript('windows: keys', [{ actions: [{ text: '123' }] }]); + const display = await calc.$('~CalculatorResults'); + const text = await display.getText(); + expect(text).toContain('123'); + }); + + it('types multi-character text into Notepad and getText returns it', async () => { + await clearNotepad(notepad); + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + await notepad.executeScript('windows: keys', [{ actions: [{ text: 'hello world' }, { pause: 100 }] }]); + const text = await textArea.getText(); + expect(text).toContain('hello world'); + }); + + it('types text with forceUnicode: true into Notepad', async () => { + await clearNotepad(notepad); + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + await notepad.executeScript('windows: keys', [{ + actions: [{ text: 'unicodetest' }, { pause: 100 }], + forceUnicode: true, + }]); + const text = await textArea.getText(); + expect(text).toContain('unicodetest'); + }); + + it('sends virtualKeyCode for Delete key to clear Notepad input', async () => { + await clearNotepad(notepad); + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + await notepad.executeScript('windows: keys', [{ actions: [{ text: 'abc' }] }]); + // Select all then delete + await notepad.executeScript('windows: keys', [{ actions: [ + { virtualKeyCode: 0x11, down: true }, // Ctrl down + { pause: 100 }, + { text: 'a' }, + { pause: 100 }, + { virtualKeyCode: 0x11, down: false }, // Ctrl up + { pause: 100 }, + { virtualKeyCode: VK_DELETE }, // Delete + ] }]); + const text = await textArea.getText(); + expect(text.trim()).toBe(''); + }); + + it('uses pause action to introduce delay between key inputs', async () => { + await expect( + calc.executeScript('windows: keys', [{ + actions: [ + { text: '1' }, + { pause: 200 }, + { text: '2' }, + ], + }]) + ).resolves.not.toThrow(); + const display = await calc.$('~CalculatorResults'); + const text = await display.getText(); + expect(text).toContain('12'); + }); + + it('sends modifier hold to produce uppercase in Notepad', async () => { + await clearNotepad(notepad); + const textArea = await getNotepadTextArea(notepad); + await textArea.click(); + await notepad.executeScript('windows: keys', [{ + actions: [ + { virtualKeyCode: VK_SHIFT, down: true }, + { text: 'a' }, + { virtualKeyCode: VK_SHIFT, down: false }, + ], + }]); + const text = await textArea.getText(); + expect(text).toContain('A'); + }); + }); + + describe('windows: click', () => { + it('clicks on the Five button by element reference and shows 5 in result', async () => { + const btn = await calc.$('~num5Button'); + await calc.executeScript('windows: click', [{ elementId: await btn.elementId }]); + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('5'); + }); + + it('clicks by absolute x/y coordinates on the Nine button', async () => { + const btn = await calc.$('~num9Button'); + const location = await btn.getLocation(); + const size = await btn.getSize(); + const windowRect = await calc.getWindowRect(); + const x = Math.round(windowRect.x + location.x + size.width / 2); + const y = Math.round(windowRect.y + location.y + size.height / 2); + await calc.executeScript('windows: click', [{ x, y }]); + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('9'); + }); + + it('clicks with button: right performs right-click without error', async () => { + const btn = await calc.$('~num1Button'); + await expect( + calc.executeScript('windows: click', [{ + elementId: await btn.elementId, + button: 'right', + }]) + ).resolves.not.toThrow(); + }); + + it('clicks with times: 3 on digit One shows 111', async () => { + const btn = await calc.$('~num1Button'); + await calc.executeScript('windows: click', [{ + elementId: await btn.elementId, + times: 3, + interClickDelayMs: 50, + }]); + const display = await calc.$('~CalculatorResults'); + expect(await display.getText()).toContain('111'); + }); + + it('clicks with durationMs: 200 as a long-press click', async () => { + const btn = await calc.$('~num2Button'); + await expect( + calc.executeScript('windows: click', [{ + elementId: await btn.elementId, + durationMs: 200, + }]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: hover', () => { + it('hovers from one button to another without error', async () => { + const startBtn = await calc.$('~num1Button'); + const endBtn = await calc.$('~num2Button'); + await expect( + calc.executeScript('windows: hover', [{ + startElementId: await startBtn.elementId, + endElementId: await endBtn.elementId, + }]) + ).resolves.not.toThrow(); + }); + + it('hovers with absolute startX/startY and endX/endY coordinates', async () => { + const btn = await calc.$('~num3Button'); + const loc = await btn.getLocation(); + const size = await btn.getSize(); + const cx = Math.round(loc.x + size.width / 2); + const cy = Math.round(loc.y + size.height / 2); + await expect( + calc.executeScript('windows: hover', [{ + startX: cx - 20, + startY: cy, + endX: cx, + endY: cy, + }]) + ).resolves.not.toThrow(); + }); + + it('hovers with a custom durationMs', async () => { + const btn = await calc.$('~num4Button'); + const endBtn = await calc.$('~num5Button'); + await expect( + calc.executeScript('windows: hover', [{ + startElementId: await btn.elementId, + endElementId: await endBtn.elementId, + durationMs: 500, + }]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: scroll', () => { + it('scrolls at element center with deltaY: 3 without error', async () => { + const btn = await calc.$('~num1Button'); + await expect( + calc.executeScript('windows: scroll', [{ + elementId: await btn.elementId, + deltaY: 3, + }]) + ).resolves.not.toThrow(); + }); + + it('scrolls at absolute coordinates', async () => { + const btn = await calc.$('~num1Button'); + const loc = await btn.getLocation(); + await expect( + calc.executeScript('windows: scroll', [{ + x: loc.x, + y: loc.y, + deltaX: 2, + }]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: clickAndDrag', () => { + it('drags from one position to another by coordinates without error', async () => { + const btn1 = await calc.$('~num1Button'); + const btn2 = await calc.$('~num2Button'); + const loc1 = await btn1.getLocation(); + const size1 = await btn1.getSize(); + const loc2 = await btn2.getLocation(); + const size2 = await btn2.getSize(); + await expect( + calc.executeScript('windows: clickAndDrag', [{ + startX: Math.round(loc1.x + size1.width / 2), + startY: Math.round(loc1.y + size1.height / 2), + endX: Math.round(loc2.x + size2.width / 2), + endY: Math.round(loc2.y + size2.height / 2), + durationMs: 300, + }]) + ).resolves.not.toThrow(); + }); + + it('drags from startElementId to endElementId center', async () => { + const startBtn = await calc.$('~num3Button'); + const endBtn = await calc.$('~num4Button'); + await expect( + calc.executeScript('windows: clickAndDrag', [{ + startElementId: await startBtn.elementId, + endElementId: await endBtn.elementId, + durationMs: 200, + }]) + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/test/e2e/extension-patterns.e2e.ts b/test/e2e/extension-patterns.e2e.ts new file mode 100644 index 0000000..3a70781 --- /dev/null +++ b/test/e2e/extension-patterns.e2e.ts @@ -0,0 +1,203 @@ +import { describe, it, beforeAll, afterAll, afterEach, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { + createCalculatorSession, + createNotepadSession, + createTodoSession, + getNotepadTextArea, + quitSession, + resetCalculator, + clearNotepad, + createTodoTask, + deleteTasks, +} from './helpers/session.js'; + +describe('windows: pattern extension commands', () => { + let calc: Browser; + let notepad: Browser; + let todo: Browser; + + // beforeAll(async () => { + // calc = await createCalculatorSession(); + // notepad = await createNotepadSession(); + // }); + + // afterAll(async () => { + // await quitSession(calc); + // await quitSession(notepad); + // }); + + describe('windows: invoke', () => { + + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + afterEach(async () => { + await resetCalculator(calc); + }); + + it('invokes the One button and result display shows 1', async () => { + const oneBtn = await calc.$('~num1Button'); + await calc.executeScript('windows: invoke', [oneBtn]); + const display = await calc.$('~CalculatorResults'); + const text = await display.getText(); + expect(text).toContain('1'); + }); + + it('invokes the Equals button without error', async () => { + await (calc.$('~num2Button')).click(); + const equalsBtn = await calc.$('~equalButton'); + await expect( + calc.executeScript('windows: invoke', [equalsBtn]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: maximize / minimize / restore', () => { + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + afterEach(async function restoreWindow() { + // Always restore to a known state + try { + await resetCalculator(calc); + const windowEl = await calc.executeScript('windows: getWindowElement', []); + await calc.executeScript('windows: restore', [windowEl]); + } catch { + // noop + } + }); + + it('maximizes the Calculator window without error', async () => { + const windowEl = await calc.executeScript('windows: getWindowElement', []); + await expect( + calc.executeScript('windows: maximize', [windowEl]) + ).resolves.not.toThrow(); + }); + + it('minimizes then restores the Calculator window', async () => { + const windowEl = await calc.executeScript('windows: getWindowElement', []); + await calc.executeScript('windows: minimize', [windowEl]); + await calc.executeScript('windows: restore', [windowEl]); + // Window should be accessible again + const display = await calc.$('~CalculatorResults'); + expect(await display.isExisting()).toBe(true); + }); + + it('restore on an already-normal window does not throw', async () => { + const windowEl = await calc.executeScript('windows: getWindowElement', []); + await expect( + calc.executeScript('windows: restore', [windowEl]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: setFocus', () => { + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + afterEach(async () => { + await resetCalculator(calc); + }); + + it('sets focus on the result display element without error', async () => { + const display = await calc.$('~CalculatorResults'); + await expect( + calc.executeScript('windows: setFocus', [display]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: scrollIntoView', () => { + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + afterEach(async () => { + await resetCalculator(calc); + }); + + it('scrolls a visible element into view without error', async () => { + const btn = await calc.$('~num1Button'); + await expect( + calc.executeScript('windows: scrollIntoView', [btn]) + ).resolves.not.toThrow(); + }); + }); + + describe('windows: setValue / getValue (ValuePattern)', () => { + beforeAll(async () => { + notepad = await createNotepadSession(); + await clearNotepad(notepad); + }); + + afterAll(async () => { + await quitSession(notepad); + }); + + it('sets a value using ValuePattern and getValue returns it', async () => { + const textArea = await getNotepadTextArea(notepad); + await notepad.executeScript('windows: setValue', [textArea, 'pattern value test']); + const result = await notepad.executeScript('windows: getValue', [textArea]); + expect(result).toContain('pattern value test'); + }); + }); + + describe('windows: select / allSelectedItems / isMultiple / toggle', () => { + beforeAll(async () => { + todo = await createTodoSession(); + await createTodoTask(todo, 'First task'); + await createTodoTask(todo, 'Second task'); + }); + + afterAll(async () => { + await deleteTasks(todo); + await quitSession(todo); + }); + + it('toggles a task checkbox in To-Do', async () => { + const checkbox = await todo.$('~CompleteTodoCheckBox'); + await expect( + todo.executeScript('windows: toggle', [checkbox]) + ).resolves.not.toThrow(); + }); + + it('select selects a task item in the To-Do list', async () => { + const item = await todo.$('//Custom/Group/List/ListItem[1]'); + await expect( + todo.executeScript('windows: select', [item]) + ).resolves.not.toThrow(); + }); + + it('isMultiple returns a boolean for the To-Do task list container', async () => { + const list = await todo.$('~TodosListView'); + const result = await todo.executeScript('windows: isMultiple', [list]); + expect(typeof result).toBe('boolean'); + }); + + it('allSelectedItems returns an array for the To-Do task list container', async () => { + const list = await todo.$('~TodosListView'); + const result = await todo.executeScript('windows: allSelectedItems', [list]); + expect(Array.isArray(result)).toBe(true); + }); + }); +}); diff --git a/test/e2e/extension-powershell.e2e.ts b/test/e2e/extension-powershell.e2e.ts new file mode 100644 index 0000000..e66d61d --- /dev/null +++ b/test/e2e/extension-powershell.e2e.ts @@ -0,0 +1,83 @@ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { createCalculatorSession, quitSession } from './helpers/session.js'; + +describe('windows: powerShell and executePowerShellScript', () => { + describe('powerShell script execution (isolatedScriptExecution: false)', () => { + let driver: Browser; + + beforeAll(async () => { + driver = await createCalculatorSession({ 'appium:isolatedScriptExecution': false }); + }); + + afterAll(async () => { + await quitSession(driver); + }); + + it('executes a simple Get-Date command and returns non-empty output', async () => { + const result = await driver.executeScript('powerShell', [{ script: 'Get-Date' }]) as string; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('executes a multi-line script and returns final output', async () => { + const result = await driver.executeScript('powerShell', [{ + script: '$a = 1 + 1\n$a', + }]) as string; + expect(result.trim()).toBe('2'); + }); + + it('variables persist between powerShell calls in the same session (non-isolated)', async () => { + await driver.executeScript('powerShell', [{ script: '$testVar = "hello"' }]); + const result = await driver.executeScript('powerShell', [{ script: '$testVar' }]) as string; + expect(result.trim()).toBe('hello'); + }); + + it('returns empty string for a script with no output', async () => { + const result = await driver.executeScript('powerShell', [{ script: '$null | Out-Null' }]) as string; + expect(result.trim()).toBe(''); + }); + + it('accepts an object with a script property', async () => { + const result = await driver.executeScript('powerShell', [{ script: '"script-prop-test"' }]) as string; + expect(result.trim()).toBe('script-prop-test'); + }); + + it('accepts an object with a command property', async () => { + const result = await driver.executeScript('powerShell', [{ command: '"command-prop-test"' }]) as string; + expect(result.trim()).toBe('command-prop-test'); + }); + + it('executes powerShell alias', async () => { + const result = await driver.executeScript('powerShell', [{ + script: '"alias-test"', + }]) as string; + expect(result.trim()).toBe('alias-test'); + }); + }); + + describe('powerShell script execution (isolatedScriptExecution: true)', () => { + let driver: Browser; + + beforeAll(async () => { + driver = await createCalculatorSession({ 'appium:isolatedScriptExecution': true }); + }); + + afterAll(async () => { + await quitSession(driver); + }); + + it('executes a script in isolated mode and returns output', async () => { + const result = await driver.executeScript('powerShell', [{ script: 'Get-Process | Select-Object -First 1 | Select-Object -ExpandProperty Name' }]) as string; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('variables do NOT persist between isolated powerShell calls', async () => { + await driver.executeScript('powerShell', [{ script: '$isolatedVar = "should-not-persist"' }]); + const result = await driver.executeScript('powerShell', [{ script: '$isolatedVar' }]) as string; + // In isolated mode each execution is fresh — variable is not defined + expect(result.trim()).toBe(''); + }); + }); +}); diff --git a/test/e2e/extension-screen-recording.e2e.ts b/test/e2e/extension-screen-recording.e2e.ts new file mode 100644 index 0000000..d94c1da --- /dev/null +++ b/test/e2e/extension-screen-recording.e2e.ts @@ -0,0 +1,122 @@ +import { existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { createCalculatorSession, quitSession, resetCalculator } from './helpers/session.js'; + +describe('windows: screen recording', () => { + let driver: Browser; + + beforeEach(async () => { + driver = await createCalculatorSession(); + }); + + afterEach(async () => { + // Ensure recording is stopped if a test left it running + try { + await driver.executeScript('windows: stopRecordingScreen', [{}]); + } catch { + // noop + } + await quitSession(driver); + }); + + describe('startRecordingScreen', () => { + it('starts screen recording without options and does not throw', async () => { + await expect( + driver.executeScript('windows: startRecordingScreen', [{}]) + ).resolves.not.toThrow(); + // Stop to clean up + await driver.executeScript('windows: stopRecordingScreen', [{}]); + }); + + it('starts recording with fps: 15 and timeLimit: 10', async () => { + await expect( + driver.executeScript('windows: startRecordingScreen', [{ + videoFps: 15, + timeLimit: 10, + }]) + ).resolves.not.toThrow(); + await driver.executeScript('windows: stopRecordingScreen', [{}]); + }); + + it('calling startRecordingScreen twice with forceRestart: true restarts recording', async () => { + await driver.executeScript('windows: startRecordingScreen', [{}]); + await expect( + driver.executeScript('windows: startRecordingScreen', [{ forceRestart: true }]) + ).resolves.not.toThrow(); + await driver.executeScript('windows: stopRecordingScreen', [{}]); + }); + + it('calling startRecordingScreen twice with forceRestart: false is a no-op', async () => { + await driver.executeScript('windows: startRecordingScreen', [{}]); + await expect( + driver.executeScript('windows: startRecordingScreen', [{ forceRestart: false }]) + ).resolves.not.toThrow(); + await driver.executeScript('windows: stopRecordingScreen', [{}]); + }); + }); + + describe('stopRecordingScreen', () => { + it('stops recording and returns a non-empty base64 string', async () => { + await driver.executeScript('windows: startRecordingScreen', [{}]); + // Interact briefly to generate some frames + await resetCalculator(driver); + const result = await driver.executeScript('windows: stopRecordingScreen', [{}]) as string; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('decoded video data starts with MP4/video file magic bytes (ftyp box)', async () => { + await driver.executeScript('windows: startRecordingScreen', [{}]); + await resetCalculator(driver); + const result = await driver.executeScript('windows: stopRecordingScreen', [{}]) as string; + const buffer = Buffer.from(result, 'base64'); + // MP4 files contain "ftyp" at bytes 4-8 + const marker = buffer.slice(4, 8).toString('ascii'); + expect(marker).toBe('ftyp'); + }); + + it('stopRecordingScreen when no recording was started returns empty string', async () => { + // No recording started + const result = await driver.executeScript('windows: stopRecordingScreen', [{}]) as string; + expect(result).toBe(''); + }); + + it('full cycle: start, interact with Calculator, stop, returns valid video data', async () => { + await driver.executeScript('windows: startRecordingScreen', [{ videoFps: 10 }]); + + // Perform some interactions + await (await driver.$('~num1Button')).click(); + await (await driver.$('~plusButton')).click(); + await (await driver.$('~num2Button')).click(); + await (await driver.$('~equalButton')).click(); + + const result = await driver.executeScript('windows: stopRecordingScreen', [{}]) as string; + expect(result.length).toBeGreaterThan(100); + }); + + it('rejects outputPath with a non-mp4 extension with an explanatory error', async () => { + const outputPath = join(tmpdir(), `novawindows-test-recording-${Date.now()}.avi`); + await expect( + driver.executeScript('windows: startRecordingScreen', [{ outputPath }]) + ).rejects.toThrow(/\.mp4/); + }); + + it('recording is saved to the specified outputPath', async () => { + const outputPath = join(tmpdir(), `novawindows-test-recording-${Date.now()}.mp4`); + try { + await driver.executeScript('windows: startRecordingScreen', [{ outputPath }]); + await resetCalculator(driver); + await driver.executeScript('windows: stopRecordingScreen', [{}]); + + expect(existsSync(outputPath)).toBe(true); + } finally { + if (existsSync(outputPath)) { + rmSync(outputPath); + } + } + }); + }); +}); diff --git a/test/e2e/find-element.e2e.ts b/test/e2e/find-element.e2e.ts new file mode 100644 index 0000000..6c9a609 --- /dev/null +++ b/test/e2e/find-element.e2e.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { closeAllTestApps, createCalculatorSession, quitSession } from './helpers/session.js'; + +describe('Element finding strategies', () => { + let driver: Browser; + + beforeAll(async () => { + closeAllTestApps; + driver = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(driver); + }); + + describe('by accessibility id', () => { + it('finds the result display by accessibility id', async () => { + const el = await driver.$('~CalculatorResults'); + expect(await el.isExisting()).toBe(true); + }); + + it('throws NoSuchElementError for a non-existent accessibility id', async () => { + await expect(driver.$('~NonExistentElement_XYZ_123').isExisting()) + .resolves.toBe(false); + }); + + it('findElements by accessibility id returns an array', async () => { + const els = await driver.$$('~num1Button'); + expect(Array.isArray(els)).toBe(true); + expect(els.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('by name', () => { + it('finds a button by Name property', async () => { + const el = await driver.findElement('name', 'One'); + expect(el).toBeDefined(); + }); + + it('findElements by name returns multiple matching elements', async () => { + const els = await driver.findElements('name', 'One'); + expect(els.length).toBeGreaterThanOrEqual(1); + }); + + it('returns empty array for non-existent name', async () => { + const els = await driver.findElements('name', 'NonExistentButtonNameXYZ'); + expect(els.length).toBe(0); + }); + }); + + describe('by xpath', () => { + it('finds a button element using XPath tag name predicate', async () => { + const el = await driver.$('//Button'); + expect(await el.isExisting()).toBe(true); + }); + + it('finds a specific button using XPath Name attribute predicate', async () => { + const el = await driver.$('//Button[@Name="One"]'); + expect(await el.isExisting()).toBe(true); + }); + + it('findElements with XPath returns multiple buttons', async () => { + const els = await driver.$$('//Button'); + expect(els.length).toBeGreaterThan(1); + }); + + it('finds element by XPath index expression', async () => { + const el = await driver.$('//Custom/Group/Group[5]/Button[1]'); + expect(await el.isExisting()).toBe(true); + }); + + it('finds a descendant scoped with relative XPath', async () => { + const parent = await driver.$('//Window'); + const child = await parent.$('.//Button'); + expect(await child.isExisting()).toBe(true); + }); + }); + + describe('by tag name (control type)', () => { + it('finds the first Button element', async () => { + const el = await driver.findElement('tag name', 'Button'); + expect(el).toBeDefined(); + }); + + it('findElements by tag name returns a list of buttons', async () => { + const els = await driver.findElements('tag name', 'Button'); + expect(els.length).toBeGreaterThan(1); + }); + + it('finds Text elements', async () => { + const els = await driver.findElements('tag name', 'Text'); + expect(els.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('by class name', () => { + it('finds an element by ClassName property', async () => { + // Calculator's main window has a known class + const els = await driver.findElements('class name', 'Windows.UI.Core.CoreWindow'); + // Either finds it or finds nothing — just verify no error + expect(Array.isArray(els)).toBe(true); + }); + }); + + describe('by id (RuntimeId)', () => { + it('finds an element by its runtime id once discovered', async () => { + // First get the element via accessibility id to retrieve its runtime id + const el = await driver.$('~CalculatorResults'); + const runtimeId = await el.getAttribute('RuntimeId'); + if (runtimeId) { + const found = await driver.findElement('id', runtimeId); + expect(found).toBeDefined(); + } else { + // RuntimeId may not be exposed; skip with a note + expect(true).toBe(true); + } + }); + }); + + describe('findElementFromElement and findElementsFromElement', () => { + it('finds a child element scoped from a parent element', async () => { + const window = await driver.$('//Window'); + const child = await window.findElement('tag name', 'Button'); + expect(child).toBeDefined(); + }); + + it('finds multiple children scoped from a parent element', async () => { + const window = await driver.$('//Window'); + const children = await window.findElements('tag name', 'Button'); + expect(children.length).toBeGreaterThan(1); + }); + + it('returns empty array when child does not exist within scope', async () => { + const btn = await driver.$('~num1Button'); + const children = await btn.findElements('xpath', './Button'); + // A single button has no button children + expect(children.length).toBe(0); + }); + }); +}); diff --git a/test/e2e/helpers/session.ts b/test/e2e/helpers/session.ts new file mode 100644 index 0000000..9a293f3 --- /dev/null +++ b/test/e2e/helpers/session.ts @@ -0,0 +1,142 @@ +import { execSync } from 'node:child_process'; +import type { Browser } from 'webdriverio'; +import { remote } from 'webdriverio'; + +export const APPIUM_SERVER = { + hostname: '127.0.0.1', + port: 4723, + path: '/', +}; + +export const CALCULATOR_APP_ID = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'; +export const NOTEPAD_APP_PATH = 'C:\\Windows\\notepad.exe'; +export const TODO_APP_ID = 'Microsoft.Todos_8wekyb3d8bbwe!App'; + +type Caps = WebdriverIO.Capabilities; + +export async function createCalculatorSession(extraCaps?: Record): Promise { + const driver = await remote({ + ...APPIUM_SERVER, + capabilities: { + platformName: 'Windows', + 'appium:automationName': 'NovaWindows', + 'appium:app': CALCULATOR_APP_ID, + ...extraCaps, + } as Caps, + }); + await driver.setTimeout({ implicit: 1500 }); + return driver; +} + +export async function createNotepadSession(extraCaps?: Record): Promise { + const driver = await remote({ + ...APPIUM_SERVER, + capabilities: { + platformName: 'Windows', + 'appium:automationName': 'NovaWindows', + 'appium:app': NOTEPAD_APP_PATH, + ...extraCaps, + } as Caps, + }); + await driver.setTimeout({ implicit: 1500 }); + return driver; +} + +export async function createTodoSession(extraCaps?: Record): Promise { + const driver = await remote({ + ...APPIUM_SERVER, + capabilities: { + platformName: 'Windows', + 'appium:automationName': 'NovaWindows', + 'appium:app': TODO_APP_ID, + ...extraCaps, + } as Caps, + }); + await driver.setTimeout({ implicit: 1500 }); + return driver; +} + +export async function createRootSession(extraCaps?: Record): Promise { + const driver = await remote({ + ...APPIUM_SERVER, + capabilities: { + platformName: 'Windows', + 'appium:automationName': 'NovaWindows', + 'appium:app': 'Root', + ...extraCaps, + } as Caps, + }); + await driver.setTimeout({ implicit: 1500 }); + return driver; +} + +/** Kill any Calculator, Notepad or To-Do processes left open by a previous test. */ +export function closeAllTestApps(): void { + for (const name of ['Calculator.exe', 'CalculatorApp.exe', 'notepad.exe', 'Microsoft.Todos.exe']) { + try { + execSync(`taskkill /F /IM "${name}"`, { stdio: 'ignore' }); + } catch { + // process not running — ok + } + } +} + +export async function quitSession(driver: Browser | null): Promise { + try { + await driver?.deleteSession(); + } catch { + // noop — session may already be terminated + } +} + +/** Click the Calculator clear button to reset the display to 0 */ +export async function resetCalculator(driver: Browser): Promise { + const clearBtn = await driver.$('~clearButton'); + await clearBtn.click(); +} + +/** Returns the Notepad text area element (modern Win11 uses Document, classic Win10 uses Edit). */ +export async function getNotepadTextArea(driver: Browser) { + const el = driver.$('//Document'); + if (await el.isExisting()) { + return el; + } + return driver.$('//Edit'); +} + +/** Clear all text in Notepad via Ctrl+A + Delete */ +export async function clearNotepad(driver: Browser): Promise { + const textArea = await getNotepadTextArea(driver); + await textArea.click(); + await driver.keys(['Control', 'a']); + await driver.keys(['Delete']); +} + + +export async function createTodoTask(driver: Browser, content: string): Promise { + const textArea = await driver.$('//Custom/Group/Edit'); + await textArea.setValue(content); + await driver.keys(['Enter']); +} + +export async function deleteTasks(driver: Browser): Promise { + const MAX_ITERATIONS = 10; + for (let i = 0; i < MAX_ITERATIONS; i++) { + const tasks = await driver.$$('//Custom/Group/List/ListItem'); + if (await tasks.length === 0) {break;} + + const elementId: string = await tasks[0].elementId; + + // Right-click the first task to open the context menu + driver.executeScript('windows: click', [{ + elementId, + button: 'right', + }]); + + // Click "Delete" in the context menu + await driver.$('//MenuItem[@Name="Delete task"]').click(); + + // Confirm the deletion in the popup dialog + await driver.$('~PrimaryButton').click(); + } +} diff --git a/test/e2e/navigation-window.e2e.ts b/test/e2e/navigation-window.e2e.ts new file mode 100644 index 0000000..3f59498 --- /dev/null +++ b/test/e2e/navigation-window.e2e.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { + createCalculatorSession, + createNotepadSession, + quitSession, +} from './helpers/session.js'; + +describe('back and forward', () => { + let notepad: Browser; + + beforeAll(async () => { + notepad = await createNotepadSession(); + }); + + afterAll(async () => { + await quitSession(notepad); + }); + + it('back() completes without error on an active window', async () => { + await expect(notepad.back()).resolves.toBeNull(); + }); + + it('forward() completes without error on an active window', async () => { + await expect(notepad.forward()).resolves.toBeNull(); + }); + + it('back() followed by forward() does not throw', async () => { + await notepad.back(); + await notepad.forward(); + }); +}); + +describe('getTitle', () => { + let notepad: Browser; + let calc: Browser; + + beforeAll(async () => { + notepad = await createNotepadSession(); + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(notepad); + await quitSession(calc); + }); + + it('returns a string for the Notepad window', async () => { + const title = await notepad.getTitle(); + expect(typeof title).toBe('string'); + expect(title.length).toBeGreaterThan(0); + }); + + it('Notepad title contains "Notepad"', async () => { + const title = await notepad.getTitle(); + expect(title).toContain('Notepad'); + }); + + it('Calculator title contains "Calculator"', async () => { + const title = await calc.getTitle(); + expect(title).toContain('Calculator'); + }); + + it('returns the same title on repeated calls', async () => { + const first = await notepad.getTitle(); + const second = await notepad.getTitle(); + expect(first).toBe(second); + }); +}); + +describe('setWindowRect', () => { + let calc: Browser; + let originalRect: { x: number; y: number; width: number; height: number }; + + beforeAll(async () => { + calc = await createCalculatorSession(); + originalRect = await calc.getWindowRect(); + }); + + afterAll(async () => { + try { + await calc.setWindowRect( + originalRect.x, + originalRect.y, + originalRect.width, + originalRect.height, + ); + } catch { + // noop — restore best-effort + } + await quitSession(calc); + }); + + it('returns a Rect object with numeric x, y, width, height', async () => { + const rect = await calc.setWindowRect(100, 100, 800, 600); + expect(typeof rect.x).toBe('number'); + expect(typeof rect.y).toBe('number'); + expect(typeof rect.width).toBe('number'); + expect(typeof rect.height).toBe('number'); + }); + + it('moves the window to the requested position', async () => { + const rect = await calc.setWindowRect(150, 150, 800, 600); + expect(rect.x).toBe(150); + expect(rect.y).toBe(150); + }); + + it('resizes only (preserves position) when x and y are null', async () => { + await calc.setWindowRect(200, 200, 800, 600); + const rect = await calc.setWindowRect(null, null, 900, 700); + expect(rect.x).toBe(200); + expect(rect.y).toBe(200); + }); + + it('moves only (preserves size) when width and height are null', async () => { + await calc.setWindowRect(100, 100, 800, 600); + const rect = await calc.setWindowRect(250, 250, null, null); + expect(rect.x).toBe(250); + expect(rect.y).toBe(250); + expect(rect.width).toBe(800); + expect(rect.height).toBe(600); + }); +}); + +describe('getElementScreenshot', () => { + let calc: Browser; + + beforeAll(async () => { + calc = await createCalculatorSession(); + }); + + afterAll(async () => { + await quitSession(calc); + }); + + it('returns a non-empty base64 string for an element', async () => { + const btn = await calc.$('~num1Button'); + const screenshot = await calc.takeElementScreenshot(await btn.elementId); + expect(typeof screenshot).toBe('string'); + expect(screenshot.length).toBeGreaterThan(0); + }); + + it('decoded bytes start with PNG magic bytes (89 50 4E 47)', async () => { + const btn = await calc.$('~num1Button'); + const screenshot = await calc.takeElementScreenshot(await btn.elementId); + const buffer = Buffer.from(screenshot, 'base64'); + expect(buffer[0]).toBe(0x89); + expect(buffer[1]).toBe(0x50); // P + expect(buffer[2]).toBe(0x4e); // N + expect(buffer[3]).toBe(0x47); // G + }); + + it('screenshot dimensions are non-zero', async () => { + const btn = await calc.$('~num1Button'); + const screenshot = await calc.takeElementScreenshot(await btn.elementId); + const buffer = Buffer.from(screenshot, 'base64'); + // PNG IHDR chunk starts at byte 16; width at 16-19, height at 20-23 + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + }); +}); diff --git a/test/e2e/session.e2e.ts b/test/e2e/session.e2e.ts new file mode 100644 index 0000000..788340f --- /dev/null +++ b/test/e2e/session.e2e.ts @@ -0,0 +1,154 @@ +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + CALCULATOR_APP_ID, + closeAllTestApps, + createCalculatorSession, + createRootSession, + quitSession, +} from './helpers/session.js'; + +// Each test creates and destroys its own session — session creation IS what is being tested. + +describe('Session creation and capabilities', () => { + beforeEach(() => closeAllTestApps()); + + it('creates a session with app capability and returns a valid session ID', async () => { + const driver = await createCalculatorSession(); + try { + const sessionId = driver.sessionId; + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + // Display should be accessible + const display = await driver.$('~CalculatorResults'); + expect(await display.isExisting()).toBe(true); + } finally { + await quitSession(driver); + } + }); + + it('creates a session with shouldCloseApp: false and app stays open after quit', async () => { + const driver = await createCalculatorSession({ 'appium:shouldCloseApp': false }); + const handle = await driver.getWindowHandle(); + + await driver.deleteSession(); + + // Verify via a Root session + const root = await createRootSession(); + try { + const handles = await root.getWindowHandles(); + expect(handles).toContain(handle); + } finally { + await quitSession(root); + } + }); + + it('creates a session with ms:waitForAppLaunch capability without error', async () => { + const driver = await createCalculatorSession({ 'ms:waitForAppLaunch': 2 }); + try { + expect(driver.sessionId).toBeTruthy(); + } finally { + await quitSession(driver); + } + }); + + it('creates a session with ms:forcequit: true without error', async () => { + const driver = await createCalculatorSession({ + 'appium:shouldCloseApp': true, + 'ms:forcequit': true, + }); + try { + expect(driver.sessionId).toBeTruthy(); + } finally { + await quitSession(driver); + } + }); + + it('creates a session with isolatedScriptExecution: true without error', async () => { + const driver = await createCalculatorSession({ 'appium:isolatedScriptExecution': true }); + try { + expect(driver.sessionId).toBeTruthy(); + } finally { + await quitSession(driver); + } + }); + + it('creates a Root session (app: Root) for the desktop root element', async () => { + const driver = await createRootSession(); + try { + expect(driver.sessionId).toBeTruthy(); + const handles = await driver.getWindowHandles(); + expect(handles.length).toBeGreaterThanOrEqual(1); + } finally { + await quitSession(driver); + } + }); + + it('creates a session with appTopLevelWindow by attaching to a running window handle', async () => { + // Start Calculator and get its native handle + const calcDriver = await createCalculatorSession({ 'appium:shouldCloseApp': false }); + const nativeHandle = await calcDriver.getWindowHandle(); + // The driver returns hex string like "0x000XXXXX"; convert to decimal for appTopLevelWindow + const numericHandle = parseInt(nativeHandle, 16); + await calcDriver.deleteSession(); + + // Attach to the same window via appTopLevelWindow + const attachedDriver = await createCalculatorSession({ + 'appium:app': undefined, + 'appium:appTopLevelWindow': numericHandle.toString(), + }); + try { + expect(attachedDriver.sessionId).toBeTruthy(); + const display = await attachedDriver.$('~CalculatorResults'); + expect(await display.isExisting()).toBe(true); + } finally { + await quitSession(attachedDriver); + } + }); + + it('creates a session with prerun script that executes before app launch', async () => { + const markerPath = join(tmpdir(), `novawindows-session-prerun-${Date.now()}.txt`); + const driver = await createCalculatorSession({ + 'appium:prerun': { + script: `New-Item -ItemType File -Path "${markerPath}" -Force | Out-Null`, + }, + }); + try { + expect(existsSync(markerPath)).toBe(true); + } finally { + await quitSession(driver); + if (existsSync(markerPath)) { unlinkSync(markerPath); } + } + }); + + it('creates a session with postrun script that executes after session deletion', async () => { + const markerPath = join(tmpdir(), `novawindows-session-postrun-${Date.now()}.txt`); + const driver = await createCalculatorSession({ + 'appium:postrun': { + script: `New-Item -ItemType File -Path "${markerPath}" -Force | Out-Null`, + }, + }); + await driver.deleteSession(); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(existsSync(markerPath)).toBe(true); + if (existsSync(markerPath)) { unlinkSync(markerPath); } + }); + + it('throws when an unknown automationName is specified', async () => { + const { remote } = await import('webdriverio'); + await expect( + remote({ + hostname: '127.0.0.1', + port: 4723, + path: '/', + capabilities: { + platformName: 'Windows', + 'appium:automationName': 'NonExistentDriver', + 'appium:app': CALCULATOR_APP_ID, + }, + }) + ).rejects.toThrow(); + }); +}); diff --git a/test/e2e/window.e2e.ts b/test/e2e/window.e2e.ts new file mode 100644 index 0000000..d80891e --- /dev/null +++ b/test/e2e/window.e2e.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Browser } from 'webdriverio'; +import { createCalculatorSession, createRootSession, quitSession } from './helpers/session.js'; + +describe('Window and app management commands', () => { + let calc: Browser; + let root: Browser; + + beforeAll(async () => { + calc = await createCalculatorSession(); + root = await createRootSession(); + }); + + afterAll(async () => { + await quitSession(calc); + await quitSession(root); + }); + + describe('getWindowHandle', () => { + it('returns a hex window handle string matching 0x format', async () => { + const handle = await calc.getWindowHandle(); + expect(handle).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('returns the same handle on repeated calls', async () => { + const first = await calc.getWindowHandle(); + const second = await calc.getWindowHandle(); + expect(first).toBe(second); + }); + }); + + describe('getWindowHandles', () => { + it('returns an array from the Root session', async () => { + const handles = await root.getWindowHandles(); + expect(Array.isArray(handles)).toBe(true); + }); + + it('returns at least one window handle from the desktop', async () => { + const handles = await root.getWindowHandles(); + expect(handles.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('getWindowRect', () => { + it('returns a rect with positive width and height', async () => { + const rect = await calc.getWindowRect(); + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + }); + + it('returns numeric x and y coordinates', async () => { + const rect = await calc.getWindowRect(); + expect(typeof rect.x).toBe('number'); + expect(typeof rect.y).toBe('number'); + }); + }); + + describe('setWindow', () => { + it('switches to a window by handle and getWindowHandle reflects it', async () => { + const calcHandle = await calc.getWindowHandle(); + await calc.switchToWindow(calcHandle); + const current = await calc.getWindowHandle(); + expect(current).toBe(calcHandle); + }); + + it('throws NoSuchWindowError for an unknown handle', async () => { + await expect(calc.switchToWindow('0xDEADBEEF')).rejects.toThrow(); + }); + }); + + describe('getPageSource', () => { + it('returns a non-empty XML string', async () => { + const source = await calc.getPageSource(); + expect(typeof source).toBe('string'); + expect(source.length).toBeGreaterThan(0); + }); + + it('XML contains Button elements for the Calculator', async () => { + const source = await calc.getPageSource(); + expect(source).toContain('Button'); + }); + + it('XML contains CalculatorResults AutomationId', async () => { + const source = await calc.getPageSource(); + expect(source).toContain('CalculatorResults'); + }); + }); + + describe('getScreenshot', () => { + it('returns a non-empty base64 string', async () => { + const screenshot = await calc.takeScreenshot(); + expect(typeof screenshot).toBe('string'); + expect(screenshot.length).toBeGreaterThan(0); + }); + + it('decoded bytes start with PNG magic bytes', async () => { + const screenshot = await calc.takeScreenshot(); + const buffer = Buffer.from(screenshot, 'base64'); + // PNG magic: 89 50 4E 47 + expect(buffer[0]).toBe(0x89); + expect(buffer[1]).toBe(0x50); // P + expect(buffer[2]).toBe(0x4e); // N + expect(buffer[3]).toBe(0x47); // G + }); + }); +}); diff --git a/test/fixtures/driver.ts b/test/fixtures/driver.ts new file mode 100644 index 0000000..3aa19bd --- /dev/null +++ b/test/fixtures/driver.ts @@ -0,0 +1,28 @@ +/** + * Shared test fixtures for extension command tests. + */ +import { vi } from 'vitest'; +import { W3C_ELEMENT_KEY } from '@appium/base-driver'; + +export interface MockDriver { + sendPowerShellCommand: ReturnType; + log: { debug: ReturnType; info?: ReturnType }; + assertFeatureEnabled: ReturnType; +} + +export function createMockDriver(overrides?: Partial): MockDriver { + const sendPowerShellCommand = vi.fn().mockResolvedValue(''); + const log = { debug: vi.fn(), info: vi.fn() }; + const assertFeatureEnabled = vi.fn(); + const driver: MockDriver = { + sendPowerShellCommand, + log, + assertFeatureEnabled, + ...overrides, + }; + return driver; +} + +export const MOCK_ELEMENT: { [W3C_ELEMENT_KEY]: string } = { + [W3C_ELEMENT_KEY]: '1.2.3.4.5', +}; diff --git a/test/powershell/common.test.ts b/test/powershell/common.test.ts new file mode 100644 index 0000000..7cc5ab8 --- /dev/null +++ b/test/powershell/common.test.ts @@ -0,0 +1,243 @@ +/** + * Unit tests for lib/powershell/common.ts (PS type wrappers) + */ +import { describe, it, expect } from 'vitest'; +import { W3C_ELEMENT_KEY } from '@appium/base-driver'; +import { + PSString, + PSBoolean, + PSInt32, + PSInt32Array, + PSAutomationHeadingLevel, + PSOrientationType, + PSControlType, + PSPoint, + PSRect, + PSAutomationElement, + PSCultureInfo, +} from '../../lib/powershell/common'; + +/** Decode the outermost base64 Invoke-Expression wrapper to reveal the PS command. */ +function decodeCommand(cmd: string): string { + const match = cmd.match(/FromBase64String\('([^']+)'\)/); + if (!match) {return cmd;} + return Buffer.from(match[1], 'base64').toString('utf8'); +} + +describe('PSString', () => { + it('wraps value in double-quotes with unicode escaping', () => { + const ps = new PSString('hello'); + expect(ps.toString()).toMatch(/^".*"$/); + }); + + it('escapes each character as unicode codepoint', () => { + const ps = new PSString('A'); + // 'A' is 0x0041 + expect(ps.toString()).toContain('0x0041'); + }); + + it('handles empty string', () => { + const ps = new PSString(''); + expect(ps.toString()).toBe('""'); + }); + + it('handles special characters', () => { + const ps = new PSString("it's a test"); + expect(ps.toString()).toMatch(/^".*"$/); + }); +}); + +describe('PSBoolean', () => { + it('returns $true for true', () => { + expect(new PSBoolean(true).toString()).toBe('$true'); + }); + + it('returns $false for false', () => { + expect(new PSBoolean(false).toString()).toBe('$false'); + }); + + it('throws for non-boolean input', () => { + expect(() => new PSBoolean('true' as any)).toThrow('PSBoolean accepts only boolean'); + expect(() => new PSBoolean(1 as any)).toThrow('PSBoolean accepts only boolean'); + expect(() => new PSBoolean(null as any)).toThrow('PSBoolean accepts only boolean'); + }); +}); + +describe('PSInt32', () => { + it('converts integer to string', () => { + expect(new PSInt32(42).toString()).toBe('42'); + expect(new PSInt32(0).toString()).toBe('0'); + expect(new PSInt32(-1).toString()).toBe('-1'); + }); + + it('throws for non-integer values', () => { + expect(() => new PSInt32(1.5)).toThrow('PSInt32 accepts only integer values'); + expect(() => new PSInt32(NaN)).toThrow('PSInt32 accepts only integer values'); + expect(() => new PSInt32(Infinity)).toThrow('PSInt32 accepts only integer values'); + }); +}); + +describe('PSInt32Array', () => { + it('wraps integer array in PS syntax', () => { + const result = new PSInt32Array([1, 2, 3]).toString(); + expect(result).toContain('1, 2, 3'); + expect(result).toContain('int32'); + }); + + it('handles empty array', () => { + const result = new PSInt32Array([]).toString(); + expect(result).toContain('int32'); + }); + + it('throws for non-array input', () => { + expect(() => new PSInt32Array('1,2,3' as any)).toThrow('PSInt32Array accepts only array of integers'); + }); + + it('throws for array with non-integer elements', () => { + expect(() => new PSInt32Array([1, 1.5, 3])).toThrow('PSInt32Array accepts only array of integers'); + }); +}); + +describe('PSAutomationHeadingLevel', () => { + it('wraps valid heading level', () => { + const ps = new PSAutomationHeadingLevel('level1'); + expect(ps.toString()).toContain('level1'); + expect(ps.originalValue).toBe('level1'); + }); + + it('accepts none heading level', () => { + expect(() => new PSAutomationHeadingLevel('none')).not.toThrow(); + }); + + it('throws for invalid heading level', () => { + expect(() => new PSAutomationHeadingLevel('level10')).toThrow('PSAutomationHeadingLevel'); + expect(() => new PSAutomationHeadingLevel('invalid')).toThrow('PSAutomationHeadingLevel'); + }); +}); + +describe('PSOrientationType', () => { + it('wraps valid orientation type', () => { + const ps = new PSOrientationType('horizontal'); + expect(ps.toString()).toContain('horizontal'); + expect(ps.originalValue).toBe('horizontal'); + }); + + it('accepts none orientation type', () => { + expect(() => new PSOrientationType('none')).not.toThrow(); + }); + + it('throws for invalid orientation type', () => { + expect(() => new PSOrientationType('diagonal')).toThrow('PSOrientationType'); + }); +}); + +describe('PSControlType', () => { + it('wraps standard control types', () => { + const ps = new PSControlType('button'); + expect(ps.toString()).toContain('button'); + }); + + it('handles SemanticZoom as "semantic zoom"', () => { + const ps = new PSControlType('semanticzoom'); + expect(ps.toString()).toBe('semantic zoom'); + }); + + it('handles AppBar as "app bar"', () => { + const ps = new PSControlType('appbar'); + expect(ps.toString()).toBe('app bar'); + }); + + it('throws for invalid control type', () => { + expect(() => new PSControlType('unknowntype')).toThrow('PSControlType'); + }); + + it('accepts all standard ControlType values', () => { + const types = ['window', 'edit', 'checkbox', 'combobox', 'list', 'listitem', 'menu', 'menuitem', 'pane', 'tab', 'tabitem']; + for (const t of types) { + expect(() => new PSControlType(t)).not.toThrow(); + } + }); +}); + +describe('PSPoint', () => { + it('creates PS point representation', () => { + const ps = new PSPoint({ x: 10, y: 20 }); + const str = ps.toString(); + expect(str).toContain('10'); + expect(str).toContain('20'); + expect(str).toContain('Point'); + }); + + it('throws for missing y coordinate', () => { + expect(() => new PSPoint({ x: 1 } as any)).toThrow('PSPoint'); + }); + + it('throws for missing x coordinate', () => { + expect(() => new PSPoint({ y: 1 } as any)).toThrow('PSPoint'); + }); + + it('throws for non-number x coordinate', () => { + expect(() => new PSPoint({ x: 'a' as any, y: 1 })).toThrow('PSPoint'); + }); +}); + +describe('PSRect', () => { + it('creates PS rect representation', () => { + const ps = new PSRect({ x: 1, y: 2, width: 100, height: 50 }); + const str = ps.toString(); + expect(str).toContain('1'); + expect(str).toContain('2'); + expect(str).toContain('100'); + expect(str).toContain('50'); + expect(str).toContain('Rect'); + }); + + it('throws for incomplete rect (missing height)', () => { + expect(() => new PSRect({ x: 1, y: 2, width: 100 } as any)).toThrow('PSRect'); + }); + + it('throws for non-number rect field', () => { + expect(() => new PSRect({ x: 'a' as any, y: 2, width: 100, height: 50 })).toThrow('PSRect'); + }); +}); + +describe('PSAutomationElement', () => { + it('wraps a W3C element using FoundAutomationElement', () => { + const element = { [W3C_ELEMENT_KEY]: '1.2.3.4.5' }; + const ps = new PSAutomationElement(element); + // The inner command is base64-encoded since FoundAutomationElement uses pwsh$ + const decoded = decodeCommand(ps.toString()); + expect(decoded).toContain('1.2.3.4.5'); + }); + + it('throws if W3C element key is missing', () => { + expect(() => new PSAutomationElement({} as any)).toThrow('PSAutomationElement'); + }); + + it('throws if W3C element key is empty string', () => { + expect(() => new PSAutomationElement({ [W3C_ELEMENT_KEY]: '' } as any)).toThrow('PSAutomationElement'); + }); +}); + +describe('PSCultureInfo', () => { + it('creates from string name', () => { + const ps = new PSCultureInfo('en-US'); + expect(ps.toString()).toContain('en-US'); + expect(ps.toString()).toContain('CultureInfo'); + }); + + it('creates from integer culture ID', () => { + const ps = new PSCultureInfo(1033); + expect(ps.toString()).toContain('1033'); + expect(ps.toString()).toContain('CultureInfo'); + }); + + it('throws for negative integer', () => { + expect(() => new PSCultureInfo(-1)).toThrow('PSCultureInfo'); + }); + + it('throws for non-string non-number input', () => { + expect(() => new PSCultureInfo([] as any)).toThrow('PSCultureInfo'); + expect(() => new PSCultureInfo(null as any)).toThrow('PSCultureInfo'); + }); +}); diff --git a/test/powershell/conditions.test.ts b/test/powershell/conditions.test.ts new file mode 100644 index 0000000..ea2309e --- /dev/null +++ b/test/powershell/conditions.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for lib/powershell/conditions.ts + */ +import { describe, it, expect } from 'vitest'; +import { + PropertyCondition, + AndCondition, + OrCondition, + NotCondition, + TrueCondition, + FalseCondition, +} from '../../lib/powershell/conditions'; +import { Property } from '../../lib/powershell/types'; +import { + PSBoolean, + PSString, + PSInt32, + PSInt32Array, + PSControlType, +} from '../../lib/powershell/common'; + +describe('TrueCondition', () => { + it('returns TrueCondition PS expression', () => { + const c = new TrueCondition(); + expect(c.toString()).toContain('TrueCondition'); + }); +}); + +describe('FalseCondition', () => { + it('returns FalseCondition PS expression', () => { + const c = new FalseCondition(); + expect(c.toString()).toContain('FalseCondition'); + }); +}); + +describe('PropertyCondition', () => { + it('creates condition for a boolean property', () => { + const c = new PropertyCondition(Property.IS_ENABLED, new PSBoolean(true)); + expect(c.toString()).toContain('isenabled'); + expect(c.toString()).toContain('$true'); + }); + + it('creates condition for a string property', () => { + const c = new PropertyCondition(Property.NAME, new PSString('Calculator')); + expect(c.toString()).toContain('name'); + }); + + it('creates condition for an int32 property', () => { + const c = new PropertyCondition(Property.NATIVE_WINDOW_HANDLE, new PSInt32(12345)); + expect(c.toString()).toContain('nativewindowhandle'); + expect(c.toString()).toContain('12345'); + }); + + it('creates condition for a control type property', () => { + const c = new PropertyCondition(Property.CONTROL_TYPE, new PSControlType('button')); + expect(c.toString()).toContain('controltype'); + }); + + it('strips trailing "property" suffix from property name', () => { + // Should still work when passing 'isenabledproperty' + const c = new PropertyCondition('isenabledproperty' as Property, new PSBoolean(false)); + expect(c.toString()).toContain('isenabled'); + }); + + it('throws when boolean property receives non-PSBoolean value', () => { + expect(() => new PropertyCondition(Property.IS_ENABLED, new PSString('true'))).toThrow(); + }); + + it('throws when string property receives non-PSString value', () => { + expect(() => new PropertyCondition(Property.NAME, new PSInt32(42))).toThrow(); + }); + + it('throws when int32 property receives non-PSInt32 value', () => { + expect(() => new PropertyCondition(Property.NATIVE_WINDOW_HANDLE, new PSBoolean(true))).toThrow(); + }); + + it('creates condition for int32 array property (RUNTIME_ID)', () => { + const c = new PropertyCondition(Property.RUNTIME_ID, new PSInt32Array([1, 2, 3])); + expect(c.toString()).toContain('runtimeid'); + }); +}); + +describe('AndCondition', () => { + it('creates AND condition from two conditions', () => { + const c1 = new PropertyCondition(Property.IS_ENABLED, new PSBoolean(true)); + const c2 = new PropertyCondition(Property.NAME, new PSString('Calc')); + const and = new AndCondition(c1, c2); + expect(and.toString()).toContain('AndCondition'); + }); + + it('creates AND condition from three conditions', () => { + const c1 = new TrueCondition(); + const c2 = new TrueCondition(); + const c3 = new FalseCondition(); + const and = new AndCondition(c1, c2, c3); + expect(and.toString()).toContain('AndCondition'); + }); + + it('throws when fewer than 2 conditions provided', () => { + const c1 = new TrueCondition(); + expect(() => new AndCondition(c1)).toThrow('at least 2 conditions'); + expect(() => new AndCondition()).toThrow('at least 2 conditions'); + }); + + it('throws when non-Condition argument is passed', () => { + const c1 = new TrueCondition(); + expect(() => new AndCondition(c1, 'not-a-condition' as any)).toThrow(); + }); +}); + +describe('OrCondition', () => { + it('creates OR condition from two conditions', () => { + const c1 = new TrueCondition(); + const c2 = new FalseCondition(); + const or = new OrCondition(c1, c2); + expect(or.toString()).toContain('OrCondition'); + }); + + it('throws when fewer than 2 conditions provided', () => { + const c1 = new TrueCondition(); + expect(() => new OrCondition(c1)).toThrow('at least 2 conditions'); + }); + + it('throws when non-Condition argument is passed', () => { + const c1 = new TrueCondition(); + expect(() => new OrCondition(c1, {} as any)).toThrow(); + }); +}); + +describe('NotCondition', () => { + it('creates NOT condition from a condition', () => { + const c = new TrueCondition(); + const not = new NotCondition(c); + expect(not.toString()).toContain('NotCondition'); + }); + + it('throws when non-Condition argument is passed', () => { + expect(() => new NotCondition('not-a-condition' as any)).toThrow(); + }); +}); diff --git a/test/powershell/converter.test.ts b/test/powershell/converter.test.ts new file mode 100644 index 0000000..9128859 --- /dev/null +++ b/test/powershell/converter.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for lib/powershell/converter.ts (convertStringToCondition) + */ +import { describe, it, expect } from 'vitest'; +import { convertStringToCondition } from '../../lib/powershell/converter'; +import { + PropertyCondition, + AndCondition, + OrCondition, + NotCondition, + TrueCondition, + FalseCondition, +} from '../../lib/powershell/conditions'; + +describe('convertStringToCondition', () => { + describe('TrueCondition / FalseCondition', () => { + it('parses PropertyCondition TrueCondition', () => { + // The TRUE_CONDITION_REGEX matches [PropertyCondition]::TrueCondition + const condition = convertStringToCondition('[PropertyCondition]::TrueCondition'); + expect(condition).toBeInstanceOf(TrueCondition); + }); + + it('parses PropertyCondition FalseCondition', () => { + const condition = convertStringToCondition('[PropertyCondition]::FalseCondition'); + expect(condition).toBeInstanceOf(FalseCondition); + }); + + it('parses Automation.RawViewCondition as TrueCondition', () => { + const condition = convertStringToCondition('[Automation]::RawViewCondition'); + expect(condition).toBeInstanceOf(TrueCondition); + }); + }); + + describe('PropertyCondition', () => { + it('parses name property condition with string value', () => { + const condition = convertStringToCondition( + "[PropertyCondition]::new([AutomationElement]::NameProperty, 'Calculator')" + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + + it('parses integer property condition (native window handle)', () => { + const condition = convertStringToCondition( + '[PropertyCondition]::new([AutomationElement]::NativeWindowHandleProperty, 12345)' + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + + it('parses control type property condition', () => { + const condition = convertStringToCondition( + '[PropertyCondition]::new([AutomationElement]::ControlTypeProperty, [ControlType]::Button)' + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + + it('parses automation id property condition', () => { + const condition = convertStringToCondition( + "[PropertyCondition]::new([AutomationElement]::AutomationIdProperty, 'btn_ok')" + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + + it('throws for unknown property name', () => { + expect(() => + convertStringToCondition( + "[PropertyCondition]::new([AutomationElement]::UnknownProp, 'value')" + ) + ).toThrow(); + }); + }); + + describe('AndCondition', () => { + it('parses AND condition with two property conditions', () => { + const condition = convertStringToCondition( + "[AndCondition]::new([PropertyCondition]::new([AutomationElement]::NameProperty, 'Calc'), [PropertyCondition]::new([AutomationElement]::NameProperty, 'Test'))" + ); + expect(condition).toBeInstanceOf(AndCondition); + }); + + it('parses AND condition with three conditions', () => { + const condition = convertStringToCondition( + "[AndCondition]::new([PropertyCondition]::new([AutomationElement]::NameProperty, 'A'), [PropertyCondition]::new([AutomationElement]::NameProperty, 'B'), [PropertyCondition]::new([AutomationElement]::NameProperty, 'C'))" + ); + expect(condition).toBeInstanceOf(AndCondition); + }); + }); + + describe('OrCondition', () => { + it('parses OR condition', () => { + const condition = convertStringToCondition( + "[OrCondition]::new([PropertyCondition]::new([AutomationElement]::NameProperty, 'A'), [PropertyCondition]::new([AutomationElement]::NameProperty, 'B'))" + ); + expect(condition).toBeInstanceOf(OrCondition); + }); + }); + + describe('NotCondition', () => { + it('parses NOT condition', () => { + const condition = convertStringToCondition( + "[NotCondition]::new([PropertyCondition]::new([AutomationElement]::NameProperty, 'test'))" + ); + expect(condition).toBeInstanceOf(NotCondition); + }); + }); + + describe('ControlView / ContentView conditions', () => { + it('parses ControlViewCondition as NotCondition', () => { + const condition = convertStringToCondition('[Automation]::ControlViewCondition'); + expect(condition).toBeInstanceOf(NotCondition); + }); + + it('parses ContentViewCondition as NotCondition', () => { + const condition = convertStringToCondition('[Automation]::ContentViewCondition'); + expect(condition).toBeInstanceOf(NotCondition); + }); + }); + + describe('integer array property condition', () => { + it('parses runtime id condition with integer array', () => { + const condition = convertStringToCondition( + '[PropertyCondition]::new([AutomationElement]::RuntimeIdProperty, [int32[]] @(1, 2, 3))' + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + }); + + describe('error handling', () => { + it('throws for an unrecognized selector', () => { + expect(() => convertStringToCondition('not a valid selector')).toThrow(); + }); + + it('throws for empty string', () => { + expect(() => convertStringToCondition('')).toThrow(); + }); + + it('throws when result is not a Condition', () => { + // A plain integer is not a Condition + expect(() => convertStringToCondition('42')).toThrow(); + }); + }); + + describe('string value handling', () => { + it('handles escaped single quotes in string values', () => { + const condition = convertStringToCondition( + "[PropertyCondition]::new([AutomationElement]::NameProperty, 'it''s')" + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + + it('handles string with special characters', () => { + const condition = convertStringToCondition( + "[PropertyCondition]::new([AutomationElement]::NameProperty, 'hello world')" + ); + expect(condition).toBeInstanceOf(PropertyCondition); + }); + }); +}); diff --git a/test/powershell/core.test.ts b/test/powershell/core.test.ts new file mode 100644 index 0000000..0e8d21b --- /dev/null +++ b/test/powershell/core.test.ts @@ -0,0 +1,59 @@ +/** + * Unit tests for lib/powershell/core.ts (pwsh and pwsh$ tagged template literals) + */ +import { describe, it, expect } from 'vitest'; +import { pwsh, pwsh$ } from '../../lib/powershell/core'; + +describe('pwsh', () => { + it('wraps command in Invoke-Expression with base64 encoding', () => { + const result = pwsh`Get-Process`; + expect(result).toContain('Invoke-Expression'); + expect(result).toContain('FromBase64String'); + // Verify the base64-encoded content decodes to the command + const base64Match = result.match(/FromBase64String\('([^']+)'\)/); + expect(base64Match).not.toBeNull(); + const decoded = Buffer.from(base64Match![1], 'base64').toString('utf8'); + expect(decoded).toBe('Get-Process'); + }); + + it('handles multi-line commands', () => { + const result = pwsh` + $a = 1 + $b = 2 + `; + const base64Match = result.match(/FromBase64String\('([^']+)'\)/); + expect(base64Match).not.toBeNull(); + const decoded = Buffer.from(base64Match![1], 'base64').toString('utf8'); + expect(decoded).toContain('$a = 1'); + expect(decoded).toContain('$b = 2'); + }); + + it('interpolates string values', () => { + const varName = '$rootElement'; + const result = pwsh`Write-Output ${varName}`; + const base64Match = result.match(/FromBase64String\('([^']+)'\)/); + const decoded = Buffer.from(base64Match![1], 'base64').toString('utf8'); + expect(decoded).toContain('$rootElement'); + }); +}); + +describe('pwsh$', () => { + it('returns a DeferredStringTemplate with base64 encoding on format', () => { + const tpl = pwsh$`Write-Output ${0}`; + const result = tpl.format('hello'); + expect(result).toContain('Invoke-Expression'); + expect(result).toContain('FromBase64String'); + const base64Match = result.match(/FromBase64String\('([^']+)'\)/); + expect(base64Match).not.toBeNull(); + const decoded = Buffer.from(base64Match![1], 'base64').toString('utf8'); + expect(decoded).toContain('hello'); + }); + + it('substitutes multiple positional arguments', () => { + const tpl = pwsh$`${0}.Method(${1})`; + const result = tpl.format('$element', '$condition'); + const base64Match = result.match(/FromBase64String\('([^']+)'\)/); + const decoded = Buffer.from(base64Match![1], 'base64').toString('utf8'); + expect(decoded).toBe('$element.Method($condition)'); + }); +}); diff --git a/test/powershell/elements.test.ts b/test/powershell/elements.test.ts new file mode 100644 index 0000000..f22d229 --- /dev/null +++ b/test/powershell/elements.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for lib/powershell/elements.ts + */ +import { describe, it, expect } from 'vitest'; +import { + AutomationElement, + FoundAutomationElement, + AutomationElementGroup, + TreeScope, +} from '../../lib/powershell/elements'; +import { TrueCondition } from '../../lib/powershell/conditions'; + +/** + * Decode the outermost base64 Invoke-Expression wrapper to reveal the PS command template. + * Only decodes one level so the caller sees the full command including any inner references. + */ +function decodeCommand(cmd: string): string { + const match = cmd.match(/FromBase64String\('([^']+)'\)/); + if (!match) {return cmd;} + return Buffer.from(match[1], 'base64').toString('utf8'); +} + +const trueCondition = new TrueCondition(); + +describe('AutomationElement static getters', () => { + it('automationRoot returns $rootElement', () => { + expect(AutomationElement.automationRoot.toString()).toBe('$rootElement'); + }); + + it('rootElement returns [AutomationElement]::RootElement', () => { + expect(AutomationElement.rootElement.toString()).toBe('[AutomationElement]::RootElement'); + }); + + it('focusedElement returns [AutomationElement]::FocusedElement', () => { + expect(AutomationElement.focusedElement.toString()).toBe('[AutomationElement]::FocusedElement'); + }); +}); + +describe('AutomationElement.buildCommand', () => { + it('wraps element in save-to-table-and-return-id command', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildCommand()); + expect(cmd).toContain('$elementTable'); + expect(cmd).toContain('$rootElement'); + }); +}); + +describe('AutomationElement.buildGetPropertyCommand', () => { + it('returns runtime ID command when property is runtimeid', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildGetPropertyCommand('runtimeid')); + expect(cmd).toContain('RuntimeIdProperty'); + }); + + it('returns tag name command when property is controltype', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildGetPropertyCommand('controltype')); + expect(cmd).toContain('ControlType'); + expect(cmd).toContain('ProgrammaticName'); + }); + + it('returns GetCurrentPropertyValue for other properties', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildGetPropertyCommand('name')); + expect(cmd).toContain('GetCurrentPropertyValue'); + expect(cmd).toContain('nameProperty'); + }); +}); + +describe('AutomationElement.buildGetElementRectCommand', () => { + it('returns BoundingRectangle command', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildGetElementRectCommand()); + expect(cmd).toContain('BoundingRectangle'); + expect(cmd).toContain('ConvertTo-Json'); + }); +}); + +describe('AutomationElement.buildSetFocusCommand', () => { + it('returns SetFocus() command', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.buildSetFocusCommand()); + expect(cmd).toContain('SetFocus'); + }); +}); + +describe('AutomationElement.findFirst', () => { + it('uses FindFirst for standard CHILDREN scope', () => { + const cmd = decodeCommand(AutomationElement.rootElement.findFirst(TreeScope.CHILDREN, trueCondition).toString()); + expect(cmd).toContain('FindFirst'); + expect(cmd).toContain('children'); + }); + + it('uses FIND_FIRST_ANCESTOR_OR_SELF for ANCESTORS_OR_SELF scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.ANCESTORS_OR_SELF, trueCondition).toString()); + expect(cmd).toContain('GetParent'); + }); + + it('uses FIND_DESCENDANTS for DESCENDANTS scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.DESCENDANTS, trueCondition).toString()); + expect(cmd).toContain('Find-ChildrenRecursively'); + }); + + it('uses FIND_DESCENDANTS_OR_SELF for SUBTREE scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.SUBTREE, trueCondition).toString()); + expect(cmd).toContain('includeSelf'); + }); + + it('uses FIND_FOLLOWING for FOLLOWING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.FOLLOWING, trueCondition).toString()); + expect(cmd).toContain('GetNextSibling'); + }); + + it('uses FIND_PRECEDING for PRECEDING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.PRECEDING, trueCondition).toString()); + expect(cmd).toContain('GetPreviousSibling'); + }); + + it('uses FIND_PARENT for PARENT scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.PARENT, trueCondition).toString()); + expect(cmd).toContain('GetParent'); + }); + + it('uses FIND_FOLLOWING_SIBLING for FOLLOWING_SIBLING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.FOLLOWING_SIBLING, trueCondition).toString()); + expect(cmd).toContain('GetNextSibling'); + }); + + it('uses FIND_PRECEDING_SIBLING for PRECEDING_SIBLING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.PRECEDING_SIBLING, trueCondition).toString()); + expect(cmd).toContain('GetPreviousSibling'); + }); + + it('uses FIND_CHILDREN_OR_SELF for CHILDREN_OR_SELF scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findFirst(TreeScope.CHILDREN_OR_SELF, trueCondition).toString()); + expect(cmd).toContain('FindFirst'); + }); +}); + +describe('AutomationElement.findAll', () => { + it('uses FindAll for standard CHILDREN scope', () => { + const cmd = decodeCommand(AutomationElement.rootElement.findAll(TreeScope.CHILDREN, trueCondition).toString()); + expect(cmd).toContain('FindAll'); + expect(cmd).toContain('children'); + }); + + it('uses FIND_ALL_DESCENDANTS for DESCENDANTS scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findAll(TreeScope.DESCENDANTS, trueCondition).toString()); + expect(cmd).toContain('Find-AllChildrenRecursively'); + }); + + it('uses FIND_ALL_ANCESTOR_OR_SELF for ANCESTORS_OR_SELF scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findAll(TreeScope.ANCESTORS_OR_SELF, trueCondition).toString()); + expect(cmd).toContain('GetParent'); + }); + + it('uses FIND_ALL_FOLLOWING for FOLLOWING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findAll(TreeScope.FOLLOWING, trueCondition).toString()); + expect(cmd).toContain('GetNextSibling'); + }); + + it('uses FIND_ALL_PRECEDING for PRECEDING scope', () => { + const cmd = decodeCommand(AutomationElement.automationRoot.findAll(TreeScope.PRECEDING, trueCondition).toString()); + expect(cmd).toContain('GetPreviousSibling'); + }); +}); + +describe('FoundAutomationElement', () => { + const el = new FoundAutomationElement('1.2.3.4.5'); + + it('stores runtimeId', () => { + expect(el.runtimeId).toBe('1.2.3.4.5'); + }); + + it('buildCommand returns element table lookup with runtimeId', () => { + const cmd = decodeCommand(el.buildCommand()); + expect(cmd).toContain('1.2.3.4.5'); + }); + + it('buildGetTextCommand returns text retrieval command', () => { + const cmd = decodeCommand(el.buildGetTextCommand()); + expect(cmd).toContain('TextPattern'); + }); + + it('buildInvokeCommand returns InvokePattern command', () => { + const cmd = decodeCommand(el.buildInvokeCommand()); + expect(cmd).toContain('InvokePattern'); + expect(cmd).toContain('Invoke()'); + }); + + it('buildExpandCommand returns ExpandCollapsePattern.Expand command', () => { + const cmd = decodeCommand(el.buildExpandCommand()); + expect(cmd).toContain('ExpandCollapsePattern'); + expect(cmd).toContain('Expand()'); + }); + + it('buildCollapseCommand returns ExpandCollapsePattern.Collapse command', () => { + const cmd = decodeCommand(el.buildCollapseCommand()); + expect(cmd).toContain('ExpandCollapsePattern'); + expect(cmd).toContain('Collapse()'); + }); + + it('buildScrollIntoViewCommand returns ScrollItemPattern command', () => { + const cmd = decodeCommand(el.buildScrollIntoViewCommand()); + expect(cmd).toContain('ScrollItemPattern'); + }); + + it('buildIsMultipleSelectCommand returns SelectionPattern command', () => { + const cmd = decodeCommand(el.buildIsMultipleSelectCommand()); + expect(cmd).toContain('SelectionPattern'); + expect(cmd).toContain('CanSelectMultiple'); + }); + + it('buildIsSelectedCommand returns SelectionItemPattern.IsSelected command', () => { + const cmd = decodeCommand(el.buildIsSelectedCommand()); + expect(cmd).toContain('SelectionItemPattern'); + expect(cmd).toContain('IsSelected'); + }); + + it('buildAddToSelectionCommand returns AddToSelection command', () => { + const cmd = decodeCommand(el.buildAddToSelectionCommand()); + expect(cmd).toContain('AddToSelection'); + }); + + it('buildRemoveFromSelectionCommand returns RemoveFromSelection command', () => { + const cmd = decodeCommand(el.buildRemoveFromSelectionCommand()); + expect(cmd).toContain('RemoveFromSelection'); + }); + + it('buildSelectCommand returns Select command', () => { + const cmd = decodeCommand(el.buildSelectCommand()); + expect(cmd).toContain('Select()'); + }); + + it('buildToggleCommand returns TogglePattern command', () => { + const cmd = decodeCommand(el.buildToggleCommand()); + expect(cmd).toContain('TogglePattern'); + }); + + it('buildSetValueCommand embeds value string', () => { + const cmd = decodeCommand(el.buildSetValueCommand('hello')); + expect(cmd).toContain('ValuePattern'); + expect(cmd).toContain('SetValue'); + }); + + it('buildSetRangeValueCommand embeds numeric value', () => { + const cmd = decodeCommand(el.buildSetRangeValueCommand('3.14')); + expect(cmd).toContain('RangeValuePattern'); + expect(cmd).toContain('SetValue'); + }); + + it('buildGetValueCommand returns ValuePattern.Value', () => { + const cmd = decodeCommand(el.buildGetValueCommand()); + expect(cmd).toContain('ValuePattern'); + expect(cmd).toContain('.Value'); + }); + + it('buildGetToggleStateCommand returns ToggleState', () => { + const cmd = decodeCommand(el.buildGetToggleStateCommand()); + expect(cmd).toContain('ToggleState'); + }); + + it('buildMaximizeCommand returns Maximized window visual state', () => { + const cmd = decodeCommand(el.buildMaximizeCommand()); + expect(cmd).toContain('Maximized'); + }); + + it('buildMinimizeCommand returns Minimized window visual state', () => { + const cmd = decodeCommand(el.buildMinimizeCommand()); + expect(cmd).toContain('Minimized'); + }); + + it('buildRestoreCommand returns Normal window visual state', () => { + const cmd = decodeCommand(el.buildRestoreCommand()); + expect(cmd).toContain('Normal'); + }); + + it('buildCloseCommand returns Close() command', () => { + const cmd = decodeCommand(el.buildCloseCommand()); + expect(cmd).toContain('WindowPattern'); + expect(cmd).toContain('Close()'); + }); + + it('buildGetTagNameCommand returns ControlType.ProgrammaticName', () => { + const cmd = decodeCommand(el.buildGetTagNameCommand()); + expect(cmd).toContain('ControlType'); + expect(cmd).toContain('ProgrammaticName'); + }); +}); + +describe('AutomationElementGroup', () => { + it('creates a group of automation elements', () => { + const el1 = AutomationElement.automationRoot; + const el2 = AutomationElement.rootElement; + const group = new AutomationElementGroup(el1, el2); + expect(group.groups).toHaveLength(2); + }); + + it('findAllGroups maps findAll over each element', () => { + const el1 = AutomationElement.automationRoot; + const el2 = AutomationElement.rootElement; + const group = new AutomationElementGroup(el1, el2); + const results = group.findAllGroups(TreeScope.CHILDREN, trueCondition); + expect(results).toHaveLength(2); + }); + + it('findFirstGroups maps findFirst over each element', () => { + const el1 = AutomationElement.automationRoot; + const el2 = AutomationElement.rootElement; + const group = new AutomationElementGroup(el1, el2); + const results = group.findFirstGroups(TreeScope.CHILDREN, trueCondition); + expect(results).toHaveLength(2); + }); +}); diff --git a/test/setup/mocks.ts b/test/setup/mocks.ts new file mode 100644 index 0000000..090a91b --- /dev/null +++ b/test/setup/mocks.ts @@ -0,0 +1,17 @@ +/** + * Global mocks for extension command tests. + * Applied via vitest.config.ts setupFiles. + */ +import { vi } from 'vitest'; + +vi.mock('../../lib/util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sleep: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('node:path', () => ({ + normalize: (p: string) => p, +})); diff --git a/test/util.test.ts b/test/util.test.ts new file mode 100644 index 0000000..758adef --- /dev/null +++ b/test/util.test.ts @@ -0,0 +1,72 @@ +/** + * Unit tests for lib/util.ts + */ +import { describe, it, expect } from 'vitest'; +import { assertSupportedEasingFunction, $ } from '../lib/util'; + +describe('assertSupportedEasingFunction', () => { + it.each(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'])( + 'accepts "%s"', + (value) => { + expect(() => assertSupportedEasingFunction(value)).not.toThrow(); + } + ); + + it('accepts cubic-bezier with valid values', () => { + expect(() => assertSupportedEasingFunction('cubic-bezier(0.25, 0.1, 0.25, 1)')).not.toThrow(); + expect(() => assertSupportedEasingFunction('cubic-bezier(0,0,1,1)')).not.toThrow(); + expect(() => assertSupportedEasingFunction('cubic-bezier(0.42, 0, 1, 1)')).not.toThrow(); + expect(() => assertSupportedEasingFunction('cubic-bezier(0, -0.5, 1, 1.5)')).not.toThrow(); + }); + + it('throws for unsupported easing function names', () => { + expect(() => assertSupportedEasingFunction('bounce')).toThrow('Unsupported or invalid easing function'); + expect(() => assertSupportedEasingFunction('spring')).toThrow('Unsupported or invalid easing function'); + expect(() => assertSupportedEasingFunction('')).toThrow('Unsupported or invalid easing function'); + }); + + it('throws for malformed cubic-bezier', () => { + // Too few args + expect(() => assertSupportedEasingFunction('cubic-bezier()')).toThrow('Unsupported or invalid easing function'); + expect(() => assertSupportedEasingFunction('cubic-bezier(0.25, 0.1, 0.25)')).toThrow('Unsupported or invalid easing function'); + // Negative x1 (regex only allows non-negative for x1 and x2) + expect(() => assertSupportedEasingFunction('cubic-bezier(-1, 0, 0, 1)')).toThrow('Unsupported or invalid easing function'); + // Non-numeric + expect(() => assertSupportedEasingFunction('cubic-bezier(abc, 0, 0, 1)')).toThrow('Unsupported or invalid easing function'); + }); +}); + +describe('DeferredStringTemplate / $', () => { + it('formats a template with a single substitution', () => { + const tpl = $`Hello ${0}!`; + expect(tpl.format('World')).toBe('Hello World!'); + }); + + it('formats a template with multiple substitutions', () => { + const tpl = $`${0} + ${1} = ${2}`; + expect(tpl.format('a', 'b', 'c')).toBe('a + b = c'); + }); + + it('formats a template with repeated substitution index', () => { + const tpl = $`${0} and ${0} again`; + expect(tpl.format('foo')).toBe('foo and foo again'); + }); + + it('throws in constructor for non-integer substitution index', () => { + expect(() => $`${1.5 as any}`).toThrow('Indices must be positive integers'); + }); + + it('throws in constructor for negative substitution index', () => { + expect(() => $`${-1 as any}`).toThrow('Indices must be positive integers'); + }); + + it('DeferredStringTemplate.format converts args to string via toString()', () => { + const tpl = $`value: ${0}`; + expect(tpl.format(42)).toBe('value: 42'); + }); + + it('handles template with no substitutions', () => { + const tpl = $`no substitutions`; + expect(tpl.format()).toBe('no substitutions'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a0d56fc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + test: { + globals: true, + include: ['test/**/*.test.ts'], + setupFiles: ['test/setup/mocks.ts'], + }, + resolve: { + alias: { + '@': resolve(__dirname, 'lib'), + }, + }, +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..3129f26 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + test: { + globals: true, + include: ['test/e2e/**/*.e2e.ts'], + // No setupFiles — real I/O, no mocks + testTimeout: 30_000, + hookTimeout: 60_000, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, // sequential execution — only one app on screen at a time + }, + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'lib'), + }, + }, +});