From f9cb5c5c8e7e88b7929171fe39c59635b721f4de Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 20 Oct 2025 07:19:16 +0000 Subject: [PATCH 01/17] chore(release): 1.2.0-preview.1 [skip ci] ## [1.2.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.1.0...v1.2.0-preview.1) (2025-10-20) ### 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)) * 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)) ### 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)) --- CHANGELOG.md | 21 +++++++++++++++++++++ package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adcfa9..3a4764f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [1.2.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.1.0...v1.2.0-preview.1) (2025-10-20) + +### 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)) +* 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)) + +### 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/package.json b/package.json index dcd5845..66a7aed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.1.0", + "version": "1.2.0-preview.1", "description": "Appium driver for Windows", "keywords": [ "appium", From 6ad9bdbdbe26cb5c7f881a8e1e80f064dfc6863b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Apr 2026 03:42:02 +0000 Subject: [PATCH 02/17] chore(release): 1.2.0-preview.2 [skip ci] ## [1.2.0-preview.2](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.2.0-preview.1...v1.2.0-preview.2) (2026-04-02) ### Bug Fixes * version bump ([a872a23](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a872a23fec5f10f692b9c61ba7f8d671f360211f)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4764f..18f15ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.2.0-preview.2](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.2.0-preview.1...v1.2.0-preview.2) (2026-04-02) + +### Bug Fixes + +* version bump ([a872a23](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a872a23fec5f10f692b9c61ba7f8d671f360211f)) + ## [1.2.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.1.0...v1.2.0-preview.1) (2025-10-20) ### Features diff --git a/package.json b/package.json index 66a7aed..6f39d89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.2.0-preview.1", + "version": "1.2.0-preview.2", "description": "Appium driver for Windows", "keywords": [ "appium", From 644034f866efcca48b87e0344b88d34f4471f608 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 2 Apr 2026 06:10:13 +0300 Subject: [PATCH 03/17] feat(webview): enable WebView2 support --- README.md | 1 + lib/commands/app.ts | 2 +- lib/commands/contexts.ts | 302 +++++++++++++++++++++++++++++++++++++ lib/commands/extension.ts | 22 ++- lib/commands/index.ts | 2 + lib/commands/powershell.ts | 30 ++++ lib/constraints.ts | 6 + lib/driver.ts | 50 +++++- package.json | 1 + 9 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 lib/commands/contexts.ts diff --git a/README.md b/README.md index 7cdc3f9..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. diff --git a/lib/commands/app.ts b/lib/commands/app.ts index e82d5ed..4faa938 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -297,7 +297,7 @@ export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, tim await sleep(SLEEP_INTERVAL_MS); } - throw new Error('Timed out waiting for window.'); + throw new errors.TimeoutError('Timed out waiting for window.'); } export async function attachToApplicationWindow(this: NovaWindowsDriver, processIds: number[]): Promise { 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/extension.ts b/lib/commands/extension.ts index f67d559..4794723 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -1,4 +1,4 @@ -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'; @@ -119,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)); } @@ -425,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, 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 ded7f2f..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,6 +12,9 @@ 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'); @@ -76,6 +80,15 @@ export async function startPowerShellSession(this: NovaWindowsDriver): 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/constraints.ts b/lib/constraints.ts index e2f82d6..7e2108b 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -42,6 +42,12 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { '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 9a0b5fd..9042fd5 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -19,17 +19,18 @@ import { TreeScope, convertStringToCondition, } from './powershell'; -import { - assertSupportedEasingFunction -} from './util'; +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'; @@ -55,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; @@ -67,7 +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); @@ -82,6 +114,18 @@ export class NovaWindowsDriver extends BaseDriver { [strategy, selector] = this.processSelector(strategy, selector); return super.findElement(strategy, selector); diff --git a/package.json b/package.json index 619b160..8562eb1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "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", From 7880d2626d956eb2e7b6e8fe4965e7bf00161e80 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Apr 2026 03:49:26 +0000 Subject: [PATCH 04/17] chore(release): 1.4.0-preview.1 [skip ci] ## [1.4.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.1...v1.4.0-preview.1) (2026-04-02) ### Features * **webview:** enable WebView2 support ([644034f](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/644034f866efcca48b87e0344b88d34f4471f608)) ### Miscellaneous Chores * **release:** 1.2.0-preview.1 [skip ci] ([f9cb5c5](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/f9cb5c5c8e7e88b7929171fe39c59635b721f4de)) * **release:** 1.2.0-preview.2 [skip ci] ([6ad9bdb](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6ad9bdbdbe26cb5c7f881a8e1e80f064dfc6863b)) --- CHANGELOG.md | 13 ++++++++++++- package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d4362..c1c20ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [1.4.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.1...v1.4.0-preview.1) (2026-04-02) + +### Features + +* **webview:** enable WebView2 support ([644034f](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/644034f866efcca48b87e0344b88d34f4471f608)) + +### Miscellaneous Chores + +* **release:** 1.2.0-preview.1 [skip ci] ([f9cb5c5](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/f9cb5c5c8e7e88b7929171fe39c59635b721f4de)) +* **release:** 1.2.0-preview.2 [skip ci] ([6ad9bdb](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6ad9bdbdbe26cb5c7f881a8e1e80f064dfc6863b)) + ## [1.3.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.0...v1.3.1) (2026-03-09) ### Bug Fixes @@ -107,4 +118,4 @@ ### Code Refactoring -* adding enums for click and updating ([89dcebf](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/89dcebfd026f7a68b4052f33fa2c928ba42162bf)) \ No newline at end of file +* adding enums for click and updating ([89dcebf](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/89dcebfd026f7a68b4052f33fa2c928ba42162bf)) diff --git a/package.json b/package.json index 8562eb1..2bc4ec6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.3.1", + "version": "1.4.0-preview.1", "description": "Appium driver for Windows", "keywords": [ "appium", From fe3f762ae666fc9de5158f03c2b7c9acb62c52c0 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 2 Apr 2026 06:21:19 +0200 Subject: [PATCH 05/17] fix: debug cdp json issue --- lib/commands/contexts.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index 53ed926..4fc5fb0 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -166,14 +166,15 @@ export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewM const waitMs = waitForWebviewMs ? Number(waitForWebviewMs) : 0; if (waitMs) { + this.log.debug(`waiting for ${waitMs} ms`); 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 }), + info: await cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })), + pages: await cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })), }; return webViewDetails; @@ -237,7 +238,11 @@ async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' return finalPath; } -async function cdpRequest({ host, port, endpoint, timeout }): Promise { +async function cdpRequest(this: NovaWindowsDriver, { host, port, endpoint, timeout }): Promise { + if (this?.log) { + this.log.debug(`Sending request to ${host}:${port}${endpoint}`); + } + return new Promise((resolve, reject) => { const options = { hostname: host, From 3bd54088887217deb5f43c3780c2aec16b3183d5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Apr 2026 04:22:39 +0000 Subject: [PATCH 06/17] chore(release): 1.4.0-preview.2 [skip ci] ## [1.4.0-preview.2](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.1...v1.4.0-preview.2) (2026-04-02) ### Bug Fixes * debug cdp json issue ([fe3f762](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/fe3f762ae666fc9de5158f03c2b7c9acb62c52c0)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c20ba..1416562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.4.0-preview.2](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.1...v1.4.0-preview.2) (2026-04-02) + +### Bug Fixes + +* debug cdp json issue ([fe3f762](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/fe3f762ae666fc9de5158f03c2b7c9acb62c52c0)) + ## [1.4.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.1...v1.4.0-preview.1) (2026-04-02) ### Features diff --git a/package.json b/package.json index 2bc4ec6..605faa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.4.0-preview.1", + "version": "1.4.0-preview.2", "description": "Appium driver for Windows", "keywords": [ "appium", From 37b9291369c30ff2a39f7618d5d20d3b9725d9a0 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 2 Apr 2026 06:34:20 +0200 Subject: [PATCH 07/17] fix: fixed port cdpRequest issue --- lib/commands/contexts.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index 53ed926..27a79df 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -170,7 +170,13 @@ export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewM } const host = 'localhost'; - const port = this.webviewDevtoolsPort; + + if ((this.caps.app === 'none' || this.caps.app === 'root' || this.caps.appTopLevelWindow != null) && this.caps.webviewDevtoolsPort == null) { + throw new errors.InvalidArgumentError(`Capability "webviewDevtoolsPort" must be set when using "none", "root", or "appTopLevelWindow" with "enableWebView"`); + } + + const port = this.webviewDevtoolsPort ??= this.caps.webviewDevtoolsPort ?? null; + const webViewDetails: WebViewDetails = { info: await cdpRequest({ host, port, endpoint: '/json/version', timeout: 10000 }), pages: await cdpRequest({ host, port, endpoint: '/json/list', timeout: 10000 }), From e6542fb88ebbfa42c5d354cc9d9767b22bb2a96e Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 2 Apr 2026 07:37:21 +0300 Subject: [PATCH 08/17] docs(readme): added enableWebView to documentation --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b22e9b..6fc62e1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ 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. +enableWebView | Whether to enable WebView support. Set to true to allow switching into WebView contexts. 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. Set a custom port if running parallel tests or when app is "none"/"root"/appTopLevelWindow is specified. 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. From 9bd5189429a705e3d59f3d32ef5cf608c17a34ec Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Apr 2026 04:39:15 +0000 Subject: [PATCH 09/17] chore(release): 1.4.0-preview.3 [skip ci] ## [1.4.0-preview.3](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.2...v1.4.0-preview.3) (2026-04-02) ### Bug Fixes * fixed port cdpRequest issue ([37b9291](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/37b9291369c30ff2a39f7618d5d20d3b9725d9a0)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1416562..e5d21db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.4.0-preview.3](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.2...v1.4.0-preview.3) (2026-04-02) + +### Bug Fixes + +* fixed port cdpRequest issue ([37b9291](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/37b9291369c30ff2a39f7618d5d20d3b9725d9a0)) + ## [1.4.0-preview.2](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.1...v1.4.0-preview.2) (2026-04-02) ### Bug Fixes diff --git a/package.json b/package.json index 605faa3..f836d7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.4.0-preview.2", + "version": "1.4.0-preview.3", "description": "Appium driver for Windows", "keywords": [ "appium", From b013bc846381ec203e6a6f90d094de85b795c36a Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 9 Apr 2026 14:05:05 +0300 Subject: [PATCH 10/17] added minimize and maximize command --- lib/commands/app.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/commands/app.ts b/lib/commands/app.ts index 4faa938..b7b8034 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -64,6 +64,24 @@ export async function getPageSource(this: NovaWindowsDriver): Promise { return await this.sendPowerShellCommand(GET_PAGE_SOURCE_COMMAND.format(AutomationElement.automationRoot)); } +export async function maximizeWindow(this: NovaWindowsDriver): Promise { + const automationRoot = new FoundAutomationElement(AutomationElement.automationRoot.buildGetPropertyCommand(Property.RUNTIME_ID)); + try { + await this.sendPowerShellCommand(automationRoot.buildMaximizeCommand()); + } catch { + throw new errors.UnknownError('Failed to maximize the window.'); + } +} + +export async function minimizeWindow(this: NovaWindowsDriver): Promise { + const automationRoot = new FoundAutomationElement(AutomationElement.automationRoot.buildGetPropertyCommand(Property.RUNTIME_ID)); + try { + await this.sendPowerShellCommand(automationRoot.buildMinimizeCommand()); + } catch { + throw new errors.UnknownError('Failed to minimize the window.'); + } +} + export async function getScreenshot(this: NovaWindowsDriver): Promise { const automationRootId = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand()); From 49aee1c2992e10d21657fa6238f5baa2a6013f6a Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 9 Apr 2026 14:05:26 +0300 Subject: [PATCH 11/17] changed finding new applicaiton window logic --- lib/commands/app.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/commands/app.ts b/lib/commands/app.ts index b7b8034..3d56438 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -305,10 +305,17 @@ export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, tim let attempts = 0; while (Date.now() - start < timeout) { - const handles = getWindowAllHandlesForProcessIds([pid]); + // const handles = getWindowAllHandlesForProcessIds([pid]); - if (handles.length > 0) { - return handles[handles.length - 1]; + // if (handles.length > 0) { + // return handles[handles.length - 1]; + // } + + const elements = await this.sendPowerShellCommand(AutomationElement.rootElement.findAll(TreeScope.CHILDREN, new PropertyCondition(Property.PROCESS_ID, new PSInt32(pid))).buildCommand()); + const elementIds = elements.split('\n').map((id) => id.trim()).filter(Boolean); + if (elementIds.length > 0) { + const nativeWindowHandle = await this.sendPowerShellCommand(new FoundAutomationElement(elementIds[0]).buildGetPropertyCommand(Property.NATIVE_WINDOW_HANDLE)); + return Number(nativeWindowHandle); } this.log.debug(`Waiting for the process window to appear... (${++attempts}/${Math.floor(timeout / SLEEP_INTERVAL_MS)})`); From 00edf24d751672123eec0cb6db0b3c34a4bfd51c Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 9 Apr 2026 14:18:05 +0300 Subject: [PATCH 12/17] fix(debug): changed logic for finding window on app launch --- lib/commands/app.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/commands/app.ts b/lib/commands/app.ts index 3d56438..fd5db08 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -305,12 +305,6 @@ export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, tim let attempts = 0; while (Date.now() - start < timeout) { - // const handles = getWindowAllHandlesForProcessIds([pid]); - - // if (handles.length > 0) { - // return handles[handles.length - 1]; - // } - const elements = await this.sendPowerShellCommand(AutomationElement.rootElement.findAll(TreeScope.CHILDREN, new PropertyCondition(Property.PROCESS_ID, new PSInt32(pid))).buildCommand()); const elementIds = elements.split('\n').map((id) => id.trim()).filter(Boolean); if (elementIds.length > 0) { From bd211782ca0e0fc5fe2cca81fff68d7bd0cdc33d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 9 Apr 2026 11:20:28 +0000 Subject: [PATCH 13/17] chore(release): 1.4.0-preview.4 [skip ci] ## [1.4.0-preview.4](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.3...v1.4.0-preview.4) (2026-04-09) ### Bug Fixes * **debug:** changed logic for finding window on app launch ([00edf24](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/00edf24d751672123eec0cb6db0b3c34a4bfd51c)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d21db..a260dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.4.0-preview.4](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.3...v1.4.0-preview.4) (2026-04-09) + +### Bug Fixes + +* **debug:** changed logic for finding window on app launch ([00edf24](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/00edf24d751672123eec0cb6db0b3c34a4bfd51c)) + ## [1.4.0-preview.3](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.2...v1.4.0-preview.3) (2026-04-02) ### Bug Fixes diff --git a/package.json b/package.json index f836d7c..5b41b68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.4.0-preview.3", + "version": "1.4.0-preview.4", "description": "Appium driver for Windows", "keywords": [ "appium", From c15567c6d06db5b1581afd441c1a1eb7887ee1b8 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 9 Apr 2026 15:39:20 +0300 Subject: [PATCH 14/17] fix(webview): fix error when no webview endpoint is available --- lib/commands/contexts.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index b87c2a5..6532d6d 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -178,10 +178,10 @@ export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewM const port = this.webviewDevtoolsPort ??= this.caps.webviewDevtoolsPort ?? null; - const webViewDetails: WebViewDetails = { - info: await cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })), - pages: await cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })), - }; + const info = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })) as Promise).catch(() => undefined); + const pages = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })) as Promise).catch(() => undefined); + + const webViewDetails: WebViewDetails = { info, pages }; return webViewDetails; } From a08f775f935b90660e1229c1ed294822a93a024e Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Thu, 9 Apr 2026 15:41:34 +0300 Subject: [PATCH 15/17] fix(webview): fix current webview not being set --- lib/commands/contexts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index 6532d6d..c12570e 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -79,6 +79,7 @@ export async function setContext(this: NovaWindowsDriver, name?: string | null): 'goog:chromeOptions': options, }; + this.currentContext = name; await this.chromedriver.start(caps); this.log.debug('Chromedriver started. Session ID:', cd.sessionId()); From 095b2af7d5a85dd757e92d5666d19ed2a20699b3 Mon Sep 17 00:00:00 2001 From: Teodor Nikolov Date: Tue, 14 Apr 2026 20:27:58 +0300 Subject: [PATCH 16/17] feat: making screen recorder ffmpeg auto-downloadable and updated webview capability names --- .gitignore | 5 + README.md | 7 +- lib/commands/app.ts | 2 +- lib/commands/contexts.ts | 118 ++++++----------- lib/commands/extension.ts | 4 +- lib/commands/powershell.ts | 2 +- lib/commands/screen-recorder.ts | 124 +++++++++++------ lib/constraints.ts | 19 ++- lib/util.ts | 228 +++++++++++++++++++++++++++++++- package.json | 5 +- 10 files changed, 377 insertions(+), 137 deletions(-) diff --git a/.gitignore b/.gitignore index d6d8ebf..ffc4b44 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,8 @@ build/ # Ignore package-lock.json in favour of npm-shrinkwrap.json for npm publishing package-lock.json* + +# Ignore downloaded chromedriver/edgedriver/ffmpeg binaries +chromedriver/ +edgedriver/ +ffmpeg/ diff --git a/README.md b/README.md index 6fc62e1..13c5549 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,13 @@ 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`. -enableWebView | Whether to enable WebView support. Set to true to allow switching into WebView contexts. Default is `false`. +webviewEnabled | Whether to enable WebView support. Set to true to allow switching into WebView contexts. 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. Set a custom port if running parallel tests or when app is "none"/"root"/appTopLevelWindow is specified. +chromedriverCdnUrl | Base URL used to download ChromeDriver binaries for automating Chromium-based WebViews in desktop applications. Defaults to `https://storage.googleapis.com/chrome-for-testing-public`. +edgedriverCdnUrl | Base URL used to download EdgeDriver binaries for automating Edge (WebView2)-based WebViews in desktop applications. Defaults to `https://msedgedriver.microsoft.com`. +edgedriverExecutablePath | Absolute file path to a locally provided Microsoft Edge WebDriver binary. When this is set, automatic download via edgedriverCdnUrl is disabled and the provided executable is used directly. The binary must be explicitly supplied by the user (e.g. downloaded manually or stored in CI artifacts). It is the user’s responsibility to ensure the driver version matches the installed Edge / WebView2 runtime version used by the target environment, otherwise automation may fail due to version incompatibility. +chromedriverExecutablePath | Absolute file path to a locally provided ChromeDriver binary. When this is set, automatic download via chromedriverCdnUrl is disabled and the provided executable is used directly. The binary must be explicitly supplied (manually downloaded or managed externally, such as in CI). The user must ensure version compatibility between ChromeDriver and the target Chromium / WebView version, as mismatches can break automation. +ffmpegExecutablePath | Absolute file path to a locally provided FFmpeg executable binary. When this is set, automatic download of FFmpeg is disabled and the provided executable is used directly. The binary must be supplied manually (e.g. downloaded and stored in CI artifacts or bundled externally). It is the user’s responsibility to ensure the FFmpeg build is compatible with the target Windows environment. If the path is invalid or the file does not exist, execution will fail with an error. 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. diff --git a/lib/commands/app.ts b/lib/commands/app.ts index fd5db08..5f702c6 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -16,7 +16,7 @@ import { import { sleep } from '../util'; import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver'; import { - getWindowAllHandlesForProcessIds, + // getWindowAllHandlesForProcessIds, keyDown, keyUp, trySetForegroundWindow, diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index c12570e..68d697a 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -1,10 +1,7 @@ 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 { cdpRequest, downloadFile, sleep, MODULE_NAME } from '../util'; import { NovaWindowsDriver } from '../driver'; import { errors } from '@appium/base-driver'; @@ -12,8 +9,6 @@ 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; } @@ -159,7 +154,7 @@ interface CDPListResponseEntry { type CDPListResponse = CDPListResponseEntry[]; export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewMs?: number): Promise { - if (!this.caps.enableWebView) { + if (!this.caps.webviewEnabled) { throw new errors.InvalidArgumentError('WebView support is not enabled. Please set the "enableWebView" capability to true and try again.'); } @@ -207,28 +202,57 @@ async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' 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 executablePath = browserType === 'Edge' + ? this.caps.edgedriverExecutablePath + : this.caps.chromedriverExecutablePath; + + if (executablePath) { + const exists = await fs.exists(executablePath); + if (!exists) { + throw new errors.InvalidArgumentError(`Driver executable not found at path: ${executablePath}`); + } + + this.log.debug( + `Using local ${browserType} driver executable at ${executablePath}. ` + + `Automatic download is disabled and CDN URLs are ignored. ` + + `You must ensure this binary matches the WebView/Chromium version (${browserVersion}).` + ); + + return executablePath; + } const arch = await system.arch(); const zipFilename = `${driverType}${browserType === 'Edge' ? '_' : '-'}win${arch}.zip`; + const CHROME_BASE_URL = this.caps.chromedriverCdnUrl || 'https://storage.googleapis.com/chrome-for-testing-public'; + const EDGE_BASE_URL = this.caps.edgedriverCdnUrl || 'https://msedgedriver.microsoft.com'; + + let downloadUrl = ''; + 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}`; + const url = new URL(CHROME_BASE_URL); + url.pathname = path.posix.join(url.pathname, browserVersion, `win${arch}`, zipFilename); + downloadUrl = url.toString(); + } else { + const url = new URL(EDGE_BASE_URL); + url.pathname = path.posix.join(url.pathname, browserVersion, zipFilename); + downloadUrl = url.toString(); } 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, @@ -237,78 +261,12 @@ async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' 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(this: NovaWindowsDriver, { host, port, endpoint, timeout }): Promise { - if (this?.log) { - this.log.debug(`Sending request to ${host}:${port}${endpoint}`); - } - 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}`)); - }); - }); + return finalPath; } diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 4794723..9280c2d 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -120,7 +120,7 @@ export async function execute(this: NovaWindowsDriver, script: string, args: any } if (script === 'mobile:getContexts') { - if (!this.caps.enableWebView) { + if (!this.caps.webviewEnabled) { 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] || {}; @@ -720,7 +720,7 @@ export async function startRecordingScreen(this: NovaWindowsDriver, args?: { } } const videoPath = outputPath ?? join(tmpdir(), `novawindows-recording-${Date.now()}.${DEFAULT_EXT}`); - this._screenRecorder = new ScreenRecorder(videoPath, this.log, { + this._screenRecorder = new ScreenRecorder(videoPath, this, { fps: fps !== undefined ? parseInt(String(fps), 10) : undefined, timeLimit: timeLimit !== undefined ? parseInt(String(timeLimit), 10) : undefined, preset, diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index 4166a5a..43f6020 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -80,7 +80,7 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise | Record; } -async function requireFfmpegPath(): Promise { - const bundled = getBundledFfmpegPath(); +async function requireFfmpegPath(driver: NovaWindowsDriver): Promise { + const bundled = await getBundledFfmpegPath(driver); if (bundled) { return bundled; } - const ffmpegBinary = `ffmpeg${system.isWindows() ? '.exe' : ''}`; + const ffmpegBinary = 'ffmpeg.exe'; try { return await fs.which(ffmpegBinary); } catch { @@ -56,24 +56,28 @@ export async function uploadRecordedMedia( 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 _driver: NovaWindowsDriver; private _videoPath: string; - private _process: SubProcess | null = null; + private _process: ChildProcessWithoutNullStreams | null = null; private _fps: number; private _audioInput?: string; private _captureCursor: boolean; @@ -82,8 +86,8 @@ export class ScreenRecorder { private _videoFilter?: string; private _timeLimit: number; - constructor(videoPath: string, log: AppiumLogger, opts: ScreenRecorderOptions = {}) { - this.log = log; + constructor(videoPath: string, driver: NovaWindowsDriver, opts: ScreenRecorderOptions = {}) { + this._driver = driver; this._videoPath = videoPath; this._fps = opts.fps && opts.fps > 0 ? opts.fps : DEFAULT_FPS; this._audioInput = opts.audioInput; @@ -108,68 +112,95 @@ export class ScreenRecorder { } isRunning(): boolean { - return !!this._process?.isRunning; + return !!this._process && !this._process.killed; } async _enforceTermination(): Promise { if (this._process && this.isRunning()) { - this.log.debug('Force-stopping the currently running video recording'); + this._driver.log.debug('Force-stopping the currently running video recording'); try { - await this._process.stop('SIGKILL'); + this._process.kill('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 ffmpegPath = await requireFfmpegPath(this._driver); const args: string[] = [ - '-loglevel', 'error', - '-t', String(this._timeLimit), - '-f', 'gdigrab', + '-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', + '-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', + '-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 = spawn(ffmpegPath, args, { windowsHide: true }); + + this._driver.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.stdout.on('data', (data: Buffer) => { + const out = data.toString(); + if (out.trim()) { + this._driver.log.debug(`[ffmpeg] ${out.trim()}`); + } + }); + + this._process.stderr.on('data', (data: Buffer) => { + const out = data.toString(); + if (out.trim()) { + this._driver.log.debug(`[ffmpeg] ${out.trim()}`); } }); this._process.once('exit', async (code: number, signal: string) => { this._process = null; + if (code === 0) { - this.log.debug('Screen recording exited without errors'); + this._driver.log.debug('Screen recording exited without errors'); } else { await this._enforceTermination(); - this.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); + this._driver.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); } }); - await this._process.start(0); + await new Promise((resolve, reject) => { + this._process?.once('error', reject); + setTimeout(resolve, 50); + }); try { await waitForCondition( @@ -192,7 +223,7 @@ export class ScreenRecorder { ); } - this.log.info( + this._driver.log.info( `The video recording has started. Will timeout in ${util.pluralize('second', this._timeLimit, true)}`, ); } @@ -203,27 +234,36 @@ export class ScreenRecorder { } if (!this.isRunning()) { - this.log.debug('Screen recording is not running. Returning the recent result'); + this._driver.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`)); + 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}`)); + reject( + new Error( + `Screen recording exited with error code ${code}, signal ${signal}`, + ), + ); } }); - this._process?.proc?.stdin?.write('q'); - this._process?.proc?.stdin?.end(); + this._process?.stdin?.write('q'); + this._process?.stdin?.end(); }); } -} +} \ No newline at end of file diff --git a/lib/constraints.ts b/lib/constraints.ts index 7e2108b..8b493cb 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -42,12 +42,27 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { 'ms:forcequit': { isBoolean: true, }, - 'enableWebView': { + webviewEnabled: { isBoolean: true, }, - 'webviewDevtoolsPort': { + webviewDevtoolsPort: { isNumber: true, }, + chromedriverCdnUrl: { + isString: true, + }, + edgedriverCdnUrl: { + isString: true, + }, + chromedriverExecutablePath: { + isString: true, + }, + edgedriverExecutablePath: { + isString: true, + }, + ffmpegExecutablePath: { + isString: true, + }, } as const satisfies Constraints; export default UI_AUTOMATION_DRIVER_CONSTRAINTS; diff --git a/lib/util.ts b/lib/util.ts index 02f8f7c..6c86296 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,19 +1,233 @@ import { errors } from '@appium/base-driver'; +import { fs, zip, node, tempDir } from 'appium/support'; +import { pipeline } from 'node:stream/promises'; +import { NovaWindowsDriver } from './driver'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; + +export const MODULE_NAME = 'appium-novawindows-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 { +export async function getBundledFfmpegPath(driver: NovaWindowsDriver): Promise { + const ffmpegExecutablePath = driver.caps.ffmpegExecutablePath; + + if (ffmpegExecutablePath) { + const exists = await fs.exists(ffmpegExecutablePath); + if (!exists) { + throw new errors.InvalidArgumentError( + `ffmpeg executable not found at: ${ffmpegExecutablePath}`, + ); + } + + return ffmpegExecutablePath; + } + + 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 exePath = path.join(root, 'ffmpeg', 'ffmpeg.exe'); + + if (await fs.exists(exePath)) { + return exePath; + } + + const tmpRoot = await tempDir.openDir(); + + driver.log.info(`ffmpeg: downloading into temp folder ${tmpRoot}`); + 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; + await fs.mkdir(tmpRoot, { recursive: true }); + + driver.log.info(`ffmpeg: fetching latest release info from GitHub`); + + const res = await fetch( + 'https://api.github.com/repos/GyanD/codexffmpeg/releases/latest', + { + headers: { + accept: 'application/vnd.github+json', + }, + }, + ); + + if (!res.ok) { + throw new errors.UnknownError( + `Failed to fetch ffmpeg release: ${res.status}`, + ); + } + + driver.log.info(`ffmpeg: parsing release metadata`); + + const release = (await res.json()) as { + assets: { name: string; browser_download_url: string }[]; + }; + + const asset = release.assets?.find((a) => + a.name.endsWith('full_build.zip'), + ); + + if (!asset?.browser_download_url) { + throw new errors.UnknownError( + 'No ffmpeg full_build.zip asset found in latest release', + ); + } + + driver.log.info(`ffmpeg: downloading ${asset.name}`); + + const zipPath = path.join(tmpRoot, 'ffmpeg.zip'); + + await downloadFile(asset.browser_download_url, tmpRoot); + + const downloadedZip = path.join( + tmpRoot, + path.basename(asset.browser_download_url), + ); + + driver.log.info(`ffmpeg: verifying downloaded archive`); + + if (!(await fs.exists(downloadedZip))) { + throw new errors.UnknownError( + `Downloaded ffmpeg zip not found at ${downloadedZip}`, + ); + } + + await fs.rename(downloadedZip, zipPath); + + driver.log.info(`ffmpeg: extracting archive`); + + await zip.extractAllTo(zipPath, tmpRoot); + + await fs.unlink(zipPath).catch(() => {}); + + driver.log.info(`ffmpeg: searching for ffmpeg.exe`); + + const found = await fs.walkDir( + tmpRoot, + true, + (itemPath: string, isDirectory: boolean) => + !isDirectory && + path.basename(itemPath).toLowerCase() === 'ffmpeg.exe', + ); + + if (!found) { + throw new errors.UnknownError('ffmpeg.exe not found after extraction'); + } + + driver.log.info(`ffmpeg: moving binary to ${exePath}`); + + await fs.mkdir(path.dirname(exePath), { recursive: true }); + await fs.mv(found, exePath, { mkdirp: true }); + + driver.log.info(`ffmpeg: ready at ${exePath}`); + + return exePath; + } finally { + driver.log.info(`ffmpeg: cleaning up temp directory`); + await fs.rimraf(tmpRoot); } } +export async function cdpRequest( + this: NovaWindowsDriver | undefined, + { host, port, endpoint, timeout }, +): Promise { + if (this?.log) { + this.log.debug(`Sending request to ${host}:${port}${endpoint}`); + } + + 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(); + }); +} + +export 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); + + const request = (currentUrl: string, redirectCount = 0): Promise => new Promise((resolve, reject) => { + const req = protocol.get(currentUrl, async (res) => { + const status = res.statusCode ?? 0; + + if ([301, 302, 307, 308].includes(status) && res.headers.location) { + if (redirectCount >= 10) { + return reject(new Error(`Too many redirects while downloading ${url}`)); + } + + const nextUrl = new URL(res.headers.location, currentUrl).toString(); + return resolve(request(nextUrl, redirectCount + 1)); + } + + if (status !== 200) { + return reject( + new Error(`Download failed from ${currentUrl}: ${status} ${res.statusMessage}`), + ); + } + + 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 ${currentUrl} after ${timeout}ms`)); + }); + }); + + return request(url); +} + const SupportedEasingFunctions = Object.freeze([ 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', ]); @@ -63,4 +277,4 @@ export class DeferredStringTemplate { } return out.join(''); } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 605faa3..7a45b03 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@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" }, @@ -61,5 +60,9 @@ "typescript-eslint": "^8.46.1", "vitest": "^2.1.0", "webdriverio": "^9.0.0" + }, + "optionalDependencies": { + "ffmpeg-win32-ia32": "npm:ffmpeg-static@4.1.0", + "ffmpeg-win32-x64": "npm:ffmpeg-static@4.1.0" } } From b4757b23d1b47144c8bb149fb41b430674c8cbea Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Apr 2026 17:35:33 +0000 Subject: [PATCH 17/17] chore(release): 1.4.0-preview.5 [skip ci] ## [1.4.0-preview.5](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.4...v1.4.0-preview.5) (2026-04-14) ### Features * making screen recorder ffmpeg auto-downloadable and updated webview capability names ([095b2af](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/095b2af7d5a85dd757e92d5666d19ed2a20699b3)) ### Bug Fixes * changed logic of attaching the root window not working on some machines ([ee12870](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/ee12870d4f83a1b830c18b65f9dc1a7493825f96)) * **webview:** fix current webview not being set ([a08f775](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a08f775f935b90660e1229c1ed294822a93a024e)) * **webview:** fix error when no webview endpoint is available ([c15567c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/c15567c6d06db5b1581afd441c1a1eb7887ee1b8)) --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a260dfc..591069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.4.0-preview.5](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.4...v1.4.0-preview.5) (2026-04-14) + +### Features + +* making screen recorder ffmpeg auto-downloadable and updated webview capability names ([095b2af](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/095b2af7d5a85dd757e92d5666d19ed2a20699b3)) + +### Bug Fixes + +* changed logic of attaching the root window not working on some machines ([ee12870](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/ee12870d4f83a1b830c18b65f9dc1a7493825f96)) +* **webview:** fix current webview not being set ([a08f775](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a08f775f935b90660e1229c1ed294822a93a024e)) +* **webview:** fix error when no webview endpoint is available ([c15567c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/c15567c6d06db5b1581afd441c1a1eb7887ee1b8)) + ## [1.4.0-preview.4](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.4.0-preview.3...v1.4.0-preview.4) (2026-04-09) ### Bug Fixes diff --git a/package.json b/package.json index 27318b4..ddb5efc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.4.0-preview.4", + "version": "1.4.0-preview.5", "description": "Appium driver for Windows", "keywords": [ "appium",